diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff67deac..326d4e51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,9 @@ jobs: outputs: wheel-distribution: ${{ steps.wheel-distribution.outputs.path }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: {fetch-depth: 0} # deep clone for setuptools-scm - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 id: setup-python with: {python-version: "3.11"} - name: Run static analysis and format checkers @@ -48,7 +48,7 @@ jobs: - name: Store the distribution files for use in other stages # `tests` and `publish` will use the same pre-built distributions, # so we make sure to release the exact same package that was tested - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: python-distribution-files path: dist/ @@ -66,13 +66,13 @@ jobs: - windows-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 id: setup-python with: python-version: ${{ matrix.python }} - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: {name: python-distribution-files, path: dist/} - name: Run tests run: >- @@ -91,7 +91,7 @@ jobs: parallel: true - name: Save coverage report if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python == '3.11' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage path: htmlcov @@ -110,7 +110,7 @@ jobs: needs: finalize runs-on: ubuntu-latest steps: - - uses: actions/create-github-app-token@v1 + - uses: actions/create-github-app-token@v3 id: github-app-checkout with: app-id: ${{ secrets.BOT_GITHUB_APP_ID }} @@ -119,11 +119,11 @@ jobs: repositories: | foapy-asv-results - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: repository: intervals-mining-lab/foapy-asv-results path: benchmarks/results @@ -131,7 +131,7 @@ jobs: token: ${{ steps.github-app-checkout.outputs.token }} persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 id: setup-python with: {python-version: "3.11"} @@ -169,15 +169,73 @@ jobs: cd ./benchmarks asv machine --machine gh-runner --yes || true asv setup -v || true - asv run ALL --skip-existing --append-samples --machine gh-runner || true + # Measure both revisions on this runner so the comparison does not + # mix results from different ephemeral GitHub-hosted machines. + printf '%s\n' \ + '${{ steps.context.outputs.base }}' \ + '${{ steps.context.outputs.head }}' \ + > ./comparison_commits.txt + asv run \ + HASHFILE:comparison_commits.txt \ + --interleave-rounds \ + --machine gh-runner || true asv publish - asv compare --machine gh-runner ${{ steps.context.outputs.base }} HEAD --split --only-changed > ./benchmark_report.md || true - cat ./benchmark_report.md >> $GITHUB_STEP_SUMMARY + asv compare \ + --machine gh-runner \ + --split \ + --only-changed \ + '${{ steps.context.outputs.base }}' \ + '${{ steps.context.outputs.head }}' \ + > ./benchmark_report.md || true + if [ -s ./benchmark_report.md ]; then - echo "has_report=true" >> $GITHUB_OUTPUT + python - <<'PY' + import os + from pathlib import Path + + report = Path("benchmark_report.md").read_text(encoding="utf-8") + run_url = ( + f"{os.environ['GITHUB_SERVER_URL']}/" + f"{os.environ['GITHUB_REPOSITORY']}/actions/runs/" + f"{os.environ['GITHUB_RUN_ID']}" + ) + suffix = ( + "\n\n_Report truncated. " + f"[Open the workflow run]({run_url}) or download the " + "`benchmark-report` artifact for the complete comparison._\n" + ) + + + def excerpt(prefix, byte_limit): + complete = prefix + report + if len(complete.encode("utf-8")) <= byte_limit: + return complete + + budget = byte_limit - len((prefix + suffix).encode("utf-8")) + lines = [] + size = 0 + for line in report.splitlines(keepends=True): + line_size = len(line.encode("utf-8")) + if size + line_size > budget: + break + lines.append(line) + size += line_size + return prefix + "".join(lines) + suffix + + + Path("benchmark_summary.md").write_text( + excerpt("", 900_000), encoding="utf-8" + ) + Path("benchmark_comment.md").write_text( + excerpt("\n\n", 60_000), + encoding="utf-8", + ) + PY + cat ./benchmark_summary.md >> "$GITHUB_STEP_SUMMARY" + echo "has_report=true" >> "$GITHUB_OUTPUT" else - echo "has_report=false" >> $GITHUB_OUTPUT + echo "has_report=false" >> "$GITHUB_OUTPUT" fi cd ./results @@ -187,7 +245,7 @@ jobs: git add . git commit -m "Update benchmark results" || true - - uses: actions/create-github-app-token@v1 + - uses: actions/create-github-app-token@v3 id: github-app-push with: app-id: ${{ secrets.BOT_GITHUB_APP_ID }} @@ -204,17 +262,41 @@ jobs: branch: ${{ steps.context.outputs.branch }} directory: benchmarks/results - - uses: mshick/add-pr-comment@v2 - if: ${{ steps.benchmark.outputs.has_report == 'true' }} - with: - message-path: benchmarks/benchmark_report.md + - name: Update benchmark PR comment + if: ${{ github.event_name == 'pull_request' && steps.benchmark.outputs.has_report == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: |- + comment_id="$( + gh api \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments?per_page=100" \ + --jq '[.[] | select(.body | startswith(""))][-1].id // empty' + )" + if [ -n "$comment_id" ]; then + gh api \ + --method PATCH \ + "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" \ + -F body=@benchmarks/benchmark_comment.md + else + gh api \ + --method POST \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + -F body=@benchmarks/benchmark_comment.md + fi - - name: Save benchmark report - uses: actions/upload-artifact@v4 + - name: Save benchmark website + uses: actions/upload-artifact@v7 with: name: benchmark path: benchmarks/html + - name: Save benchmark comparison + uses: actions/upload-artifact@v7 + with: + name: benchmark-report + path: benchmarks/benchmark_report.md + docs: needs: benchmark runs-on: ubuntu-latest @@ -240,27 +322,27 @@ jobs: # (e.g. "goruha/sync-openspec-specs"), so slugify it here. echo "docs-version=$(echo '${{ steps.context.outputs.branch }}' | tr '/' '-')" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: {fetch-depth: 0} # deep clone for setuptools-scm - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 id: setup-python with: {python-version: "3.11"} - name: Retrieve coverage report - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: coverage path: htmlcov - name: Retrieve benchmark report - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: benchmark path: benchmarks/html - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Build package distribution files run: | @@ -282,11 +364,11 @@ jobs: id-token: write contents: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: {python-version: "3.11"} - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: {name: python-distribution-files, path: dist/} - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 1fe248ca..245f5702 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,19 +1,18 @@ @@ -72,18 +71,20 @@ signatures force defensive branching in caller code and break the substitution p ### IV. Performance Requirements -All computations on sequences MUST use vectorized numpy operations; Python-level loops over array -elements are prohibited. +All production computations MUST use C-backed vectorized NumPy operations. Python iteration +constructs (`for`, `while`, comprehensions, and generator expressions) are prohibited throughout +`src/foapy/`; loops are permitted only in tests and benchmark setup code. - Operations on sequences up to length 10 000 MUST complete in < 100 ms on a single CPU core (no GPU assumption). - Memory allocation MUST be O(n) or better in sequence length; hidden quadratic allocations MUST be eliminated before merge. - Performance-sensitive paths (interval extraction, characteristic computation) MUST avoid - `numpy.vectorize` (which is a disguised Python loop) and MUST prefer `numpy.where`, boolean indexing, - `numpy.diff`, `numpy.unique`, or equivalent C-backed ufuncs. -- If a vectorized solution genuinely cannot express a required algorithm, a fallback loop MUST be - documented with a complexity note and flagged in the Complexity Tracking table. + `numpy.vectorize`, `numpy.apply_along_axis`, and similar disguised Python loops, and MUST prefer + `numpy.where`, boolean indexing, `numpy.diff`, `numpy.unique`, indexed ufunc updates, or equivalent + C-backed operations over complete arrays or batches. +- A feature that cannot yet be expressed without production Python iteration MUST remain unimplemented + until a vectorized design is available; a complexity note does not waive this rule. **Rationale**: FoaPy targets research workflows where sequences can be large and many characteristics are computed in a batch. Python loops at the inner level produce unacceptable runtimes. @@ -129,8 +130,8 @@ The following gates MUST pass before any feature branch is merged to `main`: violation is documented with a justification in the Complexity Tracking table. 4. **API consistency check**: Any new public function mirrors the signature contract defined in Principle III; `foapy.ma` parity is maintained. -5. **Performance check**: Any new sequence-processing path uses vectorized numpy; no Python loops over - array elements without a documented justification. +5. **Performance check**: Production code contains no Python iteration constructs or disguised loop + wrappers; sequence processing uses C-backed vectorized NumPy operations over complete arrays or batches. ## Governance @@ -155,4 +156,4 @@ In conflicts between this document and any other guidance, the constitution prev expected to call out constitution violations explicitly; authors are expected to resolve them before merge, not after. -**Version**: 1.0.0 | **Ratified**: 2026-03-28 | **Last Amended**: 2026-03-28 +**Version**: 1.1.0 | **Ratified**: 2026-03-28 | **Last Amended**: 2026-09-10 diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index b539a5b1..47481306 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -31,7 +31,9 @@ *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -[Gates determined based on constitution file] +- [ ] Every production array computation is designed as C-backed NumPy batch operations. +- [ ] No production `for`, `while`, comprehension, generator expression, `numpy.vectorize`, or `numpy.apply_along_axis` is planned. +- [ ] Tests and benchmarks cover correctness and performance of the vectorized design. ## Project Structure diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md index 8accc1d7..ebea7caa 100644 --- a/.specify/templates/tasks-template.md +++ b/.specify/templates/tasks-template.md @@ -153,6 +153,7 @@ Examples of foundational tasks (adjust based on your project): - [ ] TXXX [P] Documentation updates in docs/ - [ ] TXXX Code cleanup and refactoring - [ ] TXXX Performance optimization across all stories +- [ ] TXXX Audit production code for Python iteration constructs and disguised loop wrappers - [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ - [ ] TXXX Security hardening - [ ] TXXX Run quickstart.md validation diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ff1b1d31 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# FoaPy agent instructions + +- Production code under `src/foapy/` MUST NOT contain Python iteration constructs: `for`, `while`, comprehensions, or generator expressions. Use C-backed NumPy operations that process complete arrays or batches instead. +- Do not use `numpy.vectorize`, `numpy.apply_along_axis`, or similar wrappers that merely hide Python iteration. +- Python loops are permitted only in tests and benchmark setup code. +- The project constitution in `.specify/memory/constitution.md` is authoritative and must be followed for every change. diff --git a/benchmarks/benchmarks/bench_alphabet.py b/benchmarks/benchmarks/bench_alphabet.py index fb385474..964b976d 100644 --- a/benchmarks/benchmarks/bench_alphabet.py +++ b/benchmarks/benchmarks/bench_alphabet.py @@ -4,7 +4,7 @@ from foapy import alphabet -from .cases import best_case, dna_case, normal_case, worst_case +from .cases import best_case, dna_case, normal_case, records_case, worst_case length = [5, 50, 500, 5000, 50000, 500000, 5000000, 50000000] skip = [ @@ -42,3 +42,19 @@ def time_alphabet(self, length, case): @skip_params_if(skip, os.getenv("QUICK_BENCHMARK") == "true") def peakmem_alphabet(self, length, case): return alphabet(self.data) + + +class AxisAlphabetSuite: + params = ([5, 50, 500, 5000, 50000], [2, 8], [0, 1]) + param_names = ["length", "record_width", "axis"] + + data = None + + def setup(self, length, record_width, axis): + self.data = records_case(length, record_width, axis) + + def time_alphabet(self, length, record_width, axis): + alphabet(self.data, axis=axis) + + def peakmem_alphabet(self, length, record_width, axis): + return alphabet(self.data, axis=axis) diff --git a/benchmarks/benchmarks/bench_intervals_chain.py b/benchmarks/benchmarks/bench_intervals_chain.py index 9850459a..8bc0c34a 100644 --- a/benchmarks/benchmarks/bench_intervals_chain.py +++ b/benchmarks/benchmarks/bench_intervals_chain.py @@ -4,7 +4,7 @@ from foapy.core import intervals_chain -from .cases import best_case, dna_case, normal_case, worst_case +from .cases import best_case, dna_case, normal_case, records_case, worst_case length = [100, 10_000, 1_000_000] skip = [ @@ -39,3 +39,32 @@ def time_intervals_chain(self, length, case, b, cm): @skip_params_if(skip, os.getenv("QUICK_BENCHMARK") == "true") def peakmem_intervals_chain(self, length, case, b, cm): return intervals_chain(self.data, self.binding, self.chain_mode) + + +axis_length = [100, 10_000, 1_000_000] +axis_skip = [ + (1_000_000, width, axis, binding, chain_mode) + for width in (2, 8) + for axis in (0, 1) + for binding in (1, 2) + for chain_mode in (1, 2) +] + + +class AxisIntervalsChainSuite: + params = (axis_length, [2, 8], [0, 1], [1, 2], [1, 2]) + param_names = ["length", "record_width", "axis", "binding", "chain_mode"] + timeout = 600 + + def setup(self, length, record_width, axis, binding, chain_mode): + self.data = records_case(length, record_width, axis) + self.binding = binding + self.chain_mode = chain_mode + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def time_intervals_chain(self, length, record_width, axis, binding, chain_mode): + intervals_chain(self.data, self.binding, self.chain_mode, axis=axis) + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def peakmem_intervals_chain(self, length, record_width, axis, binding, chain_mode): + return intervals_chain(self.data, self.binding, self.chain_mode, axis=axis) diff --git a/benchmarks/benchmarks/bench_intervals_distribution.py b/benchmarks/benchmarks/bench_intervals_distribution.py index 1956ee7a..0d09469e 100644 --- a/benchmarks/benchmarks/bench_intervals_distribution.py +++ b/benchmarks/benchmarks/bench_intervals_distribution.py @@ -1,3 +1,9 @@ +import os + +import numpy as np +import numpy.ma as ma +from asv_runner.benchmarks.mark import skip_params_if + from foapy import binding, chain_mode from foapy.core import ( intervals_chain, @@ -33,3 +39,45 @@ def time_intervals_distribution(self, length, case): def peakmem_intervals_distribution(self, length, case): return intervals_distribution(self.tuple_result) + + +axis_length = [100, 10_000, 1_000_000] +axis_cases = ["Plain", "Masked"] +axis_skip = [ + (1_000_000, lane_count, axis, case) + for lane_count in (2, 8) + for axis in (0, 1) + for case in axis_cases +] + + +def _tuples_case(length, lane_count, axis, case): + lanes = np.empty((lane_count, length), dtype=np.intp) + for index in range(lane_count): + maximum = 4 if index % 2 == 0 else 8 + lanes[index] = np.arange(length, dtype=np.intp) % maximum + 1 + + if case == "Masked": + mask = np.zeros_like(lanes, dtype=bool) + mask[:, (length * 3) // 4 :] = True + lanes = ma.masked_array(lanes, mask=mask) + + return lanes if axis == 1 else lanes.T + + +class AxisIntervalsDistributionSuite: + params = (axis_length, [2, 8], [0, 1], axis_cases) + param_names = ["length", "lane_count", "axis", "case"] + timeout = 600 + + def setup(self, length, lane_count, axis, case): + self.tuples = _tuples_case(length, lane_count, axis, case) + self.axis = axis + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def time_intervals_distribution(self, length, lane_count, axis, case): + intervals_distribution(self.tuples, axis=self.axis) + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def peakmem_intervals_distribution(self, length, lane_count, axis, case): + return intervals_distribution(self.tuples, axis=self.axis) diff --git a/benchmarks/benchmarks/bench_intervals_tuple.py b/benchmarks/benchmarks/bench_intervals_tuple.py index 23f0b082..7025d437 100644 --- a/benchmarks/benchmarks/bench_intervals_tuple.py +++ b/benchmarks/benchmarks/bench_intervals_tuple.py @@ -1,3 +1,8 @@ +import os + +import numpy as np +from asv_runner.benchmarks.mark import skip_params_if + from foapy import binding, chain_mode from foapy.core import intervals_chain, intervals_tuple @@ -28,3 +33,58 @@ def time_intervals_tuple(self, length, case, tm): def peakmem_intervals_tuple(self, length, case, tm): return intervals_tuple(self.chain, binding.start, self.tuple_mode) + + +axis_length = [100, 10_000, 1_000_000] +axis_cases = ["Uniform", "Variable"] +axis_skip = [ + (1_000_000, lane_count, axis, case, binding_value, tuple_mode_value) + for lane_count in (2, 8) + for axis in (0, 1) + for case in axis_cases + for binding_value in (1, 2) + for tuple_mode_value in (1, 2, 3) +] + + +def _chains_case(length, lane_count, axis, case, binding_value): + chains = np.ones((lane_count, length), dtype=np.intp) + if case == "Variable": + boundary = np.arange(1, length + 1, dtype=np.intp) + if binding_value == binding.end: + boundary = boundary[::-1] + chains[1::2] = boundary + return chains if axis == 1 else chains.T + + +class AxisIntervalsTupleSuite: + params = (axis_length, [2, 8], [0, 1], axis_cases, [1, 2], [1, 2, 3]) + param_names = [ + "length", + "lane_count", + "axis", + "case", + "binding", + "tuple_mode", + ] + timeout = 600 + + def setup(self, length, lane_count, axis, case, binding_value, tuple_mode_value): + self.chains = _chains_case(length, lane_count, axis, case, binding_value) + self.axis = axis + self.binding = binding_value + self.tuple_mode = tuple_mode_value + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def time_intervals_tuple( + self, length, lane_count, axis, case, binding_value, tuple_mode_value + ): + intervals_tuple(self.chains, self.binding, self.tuple_mode, axis=self.axis) + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def peakmem_intervals_tuple( + self, length, lane_count, axis, case, binding_value, tuple_mode_value + ): + return intervals_tuple( + self.chains, self.binding, self.tuple_mode, axis=self.axis + ) diff --git a/benchmarks/benchmarks/bench_order.py b/benchmarks/benchmarks/bench_order.py index 471230a2..2721866b 100644 --- a/benchmarks/benchmarks/bench_order.py +++ b/benchmarks/benchmarks/bench_order.py @@ -4,7 +4,7 @@ from foapy import order -from .cases import best_case, dna_case, normal_case, worst_case +from .cases import best_case, dna_case, normal_case, records_case, worst_case length = [5, 50, 500, 5000, 50000, 500000, 5000000, 50000000] skip = [ @@ -44,3 +44,19 @@ def time_order(self, length, case): @skip_params_if(skip, os.getenv("QUICK_BENCHMARK") == "true") def peakmem_order(self, length, case): return order(self.data) + + +class AxisOrderSuite: + params = ([5, 50, 500, 5000, 50000], [2, 8], [0, 1]) + param_names = ["length", "record_width", "axis"] + + data = None + + def setup(self, length, record_width, axis): + self.data = records_case(length, record_width, axis) + + def time_order(self, length, record_width, axis): + order(self.data, axis=axis) + + def peakmem_order(self, length, record_width, axis): + return order(self.data, axis=axis) diff --git a/benchmarks/benchmarks/bench_partials_alphabet.py b/benchmarks/benchmarks/bench_partials_alphabet.py index 3f64a4c6..0ceedeab 100644 --- a/benchmarks/benchmarks/bench_partials_alphabet.py +++ b/benchmarks/benchmarks/bench_partials_alphabet.py @@ -6,7 +6,14 @@ from foapy.partials import alphabet -from .cases import best_case, dna_case, normal_case, worst_case +from .cases import ( + best_case, + dna_case, + normal_case, + records_case, + whole_slice_mask, + worst_case, +) length = [5, 50, 500, 5000, 50000, 500000, 5000000, 50000000] cases = ["Best", "DNA", "Normal", "Worst"] @@ -46,3 +53,26 @@ def time_alphabet(self, length, case): @skip_params_if(skip, os.getenv("QUICK_BENCHMARK") == "true") def peakmem_alphabet(self, length, case): return alphabet(self.data) + + +class PartialsAxisAlphabetSuite: + params = ( + [5, 50, 500, 5000, 50000], + [2, 8], + [0, 1], + ["Unmasked", "Gapped", "FullyMasked"], + ) + param_names = ["length", "record_width", "axis", "case"] + + data = None + + def setup(self, length, record_width, axis, case): + source = records_case(length, record_width, axis) + mask = whole_slice_mask(length, record_width, axis, case) + self.data = ma.masked_array(source, mask=mask) + + def time_alphabet(self, length, record_width, axis, case): + alphabet(self.data, axis=axis) + + def peakmem_alphabet(self, length, record_width, axis, case): + return alphabet(self.data, axis=axis) diff --git a/benchmarks/benchmarks/bench_partials_intervals_chain.py b/benchmarks/benchmarks/bench_partials_intervals_chain.py index e0a6fdf6..5b7a8386 100644 --- a/benchmarks/benchmarks/bench_partials_intervals_chain.py +++ b/benchmarks/benchmarks/bench_partials_intervals_chain.py @@ -6,7 +6,14 @@ from foapy.partials import intervals_chain -from .cases import best_case, dna_case, normal_case, worst_case +from .cases import ( + best_case, + dna_case, + normal_case, + records_case, + whole_slice_mask, + worst_case, +) length = [100, 10_000, 1_000_000] cases = ["Dense", "PartialDNA", "PartialNormal", "FullyMasked"] @@ -50,3 +57,47 @@ def time_intervals_chain(self, length, case, binding, chain_mode): @skip_params_if(skip, os.getenv("QUICK_BENCHMARK") == "true") def peakmem_intervals_chain(self, length, case, binding, chain_mode): return intervals_chain(self.data, self.binding, self.chain_mode) + + +axis_length = [100, 10_000, 1_000_000] +axis_cases = ["Unmasked", "Gapped", "FullyMasked"] +axis_skip = [ + (1_000_000, width, axis, case, binding, chain_mode) + for width in (2, 8) + for axis in (0, 1) + for case in axis_cases + for binding in (1, 2) + for chain_mode in (1, 2) +] + + +class PartialsAxisIntervalsChainSuite: + params = (axis_length, [2, 8], [0, 1], axis_cases, [1, 2], [1, 2]) + param_names = [ + "length", + "record_width", + "axis", + "case", + "binding", + "chain_mode", + ] + timeout = 600 + + def setup(self, length, record_width, axis, case, binding, chain_mode): + source = records_case(length, record_width, axis) + mask = whole_slice_mask(length, record_width, axis, case) + self.data = ma.masked_array(source, mask=mask) + self.binding = binding + self.chain_mode = chain_mode + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def time_intervals_chain( + self, length, record_width, axis, case, binding, chain_mode + ): + intervals_chain(self.data, self.binding, self.chain_mode, axis=axis) + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def peakmem_intervals_chain( + self, length, record_width, axis, case, binding, chain_mode + ): + return intervals_chain(self.data, self.binding, self.chain_mode, axis=axis) diff --git a/benchmarks/benchmarks/bench_partials_intervals_tuple.py b/benchmarks/benchmarks/bench_partials_intervals_tuple.py new file mode 100644 index 00000000..12e41d46 --- /dev/null +++ b/benchmarks/benchmarks/bench_partials_intervals_tuple.py @@ -0,0 +1,141 @@ +import os + +import numpy as np +import numpy.ma as ma +from asv_runner.benchmarks.mark import skip_params_if + +from foapy.partials import intervals_tuple + +lengths = [100, 10_000, 1_000_000] +gap_cases = ["Dense", "Gapped"] +timeout = 600 + + +def _partial_chain_rows(length, lane_count, gap_case, binding_value): + data = np.zeros((lane_count, length), dtype=np.intp) + mask = np.zeros_like(data, dtype=bool) + + for lane_index in range(lane_count): + if gap_case == "Gapped": + mask[lane_index, lane_index % 7 :: 7] = True + + positions = np.flatnonzero(~mask[lane_index]) + if binding_value == 1: + values = np.diff(np.concatenate(([-1], positions))) + else: + values = np.diff(np.concatenate((positions, [length]))) + data[lane_index, positions] = values + + return ma.masked_array(data, mask=mask) + + +one_dimensional_skip = [ + (1_000_000, gap_case, binding_value, tuple_mode_value) + for gap_case in gap_cases + for binding_value in (1, 2) + for tuple_mode_value in (1, 2, 3) +] + + +class PartialsIntervalsTupleSuite: + params = (lengths, gap_cases, [1, 2], [1, 2, 3]) + param_names = ["length", "case", "binding", "tuple_mode"] + timeout = 600 + + def setup(self, length, gap_case, binding_value, tuple_mode_value): + self.chain = _partial_chain_rows(length, 1, gap_case, binding_value)[0] + self.binding = binding_value + self.tuple_mode = tuple_mode_value + + @skip_params_if( + one_dimensional_skip, + os.getenv("QUICK_BENCHMARK") == "true", + ) + def time_intervals_tuple(self, length, gap_case, binding_value, tuple_mode_value): + intervals_tuple(self.chain, self.binding, self.tuple_mode) + + @skip_params_if( + one_dimensional_skip, + os.getenv("QUICK_BENCHMARK") == "true", + ) + def peakmem_intervals_tuple( + self, length, gap_case, binding_value, tuple_mode_value + ): + return intervals_tuple(self.chain, self.binding, self.tuple_mode) + + +axis_skip = [ + (1_000_000, lane_count, axis, gap_case, binding_value, tuple_mode_value) + for lane_count in (2, 8) + for axis in (0, 1) + for gap_case in gap_cases + for binding_value in (1, 2) + for tuple_mode_value in (1, 2, 3) +] + + +class AxisPartialsIntervalsTupleSuite: + params = (lengths, [2, 8], [0, 1], gap_cases, [1, 2], [1, 2, 3]) + param_names = [ + "length", + "lane_count", + "axis", + "case", + "binding", + "tuple_mode", + ] + timeout = 600 + + def setup( + self, + length, + lane_count, + axis, + gap_case, + binding_value, + tuple_mode_value, + ): + chains = _partial_chain_rows( + length, + lane_count, + gap_case, + binding_value, + ) + self.chains = chains if axis == 1 else chains.T + self.axis = axis + self.binding = binding_value + self.tuple_mode = tuple_mode_value + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def time_intervals_tuple( + self, + length, + lane_count, + axis, + gap_case, + binding_value, + tuple_mode_value, + ): + intervals_tuple( + self.chains, + self.binding, + self.tuple_mode, + axis=self.axis, + ) + + @skip_params_if(axis_skip, os.getenv("QUICK_BENCHMARK") == "true") + def peakmem_intervals_tuple( + self, + length, + lane_count, + axis, + gap_case, + binding_value, + tuple_mode_value, + ): + return intervals_tuple( + self.chains, + self.binding, + self.tuple_mode, + axis=self.axis, + ) diff --git a/benchmarks/benchmarks/bench_partials_order.py b/benchmarks/benchmarks/bench_partials_order.py index be1b7e52..1c5b6e7a 100644 --- a/benchmarks/benchmarks/bench_partials_order.py +++ b/benchmarks/benchmarks/bench_partials_order.py @@ -6,7 +6,14 @@ from foapy.partials import order -from .cases import best_case, dna_case, normal_case, worst_case +from .cases import ( + best_case, + dna_case, + normal_case, + records_case, + whole_slice_mask, + worst_case, +) length = [5, 50, 500, 5000, 50000, 500000, 5000000, 50000000] cases = ["Unmasked", "PartialDNA", "PartialNormal", "FullyMasked"] @@ -39,3 +46,26 @@ def time_order(self, length, case): @skip_params_if(skip, os.getenv("QUICK_BENCHMARK") == "true") def peakmem_order(self, length, case): return order(self.data) + + +class PartialsAxisOrderSuite: + params = ( + [5, 50, 500, 5000, 50000], + [2, 8], + [0, 1], + ["Unmasked", "Gapped", "FullyMasked"], + ) + param_names = ["length", "record_width", "axis", "case"] + + data = None + + def setup(self, length, record_width, axis, case): + source = records_case(length, record_width, axis) + mask = whole_slice_mask(length, record_width, axis, case) + self.data = ma.masked_array(source, mask=mask) + + def time_order(self, length, record_width, axis, case): + order(self.data, axis=axis) + + def peakmem_order(self, length, record_width, axis, case): + return order(self.data, axis=axis) diff --git a/benchmarks/benchmarks/cases.py b/benchmarks/benchmarks/cases.py index 8424951a..c7fef8b2 100644 --- a/benchmarks/benchmarks/cases.py +++ b/benchmarks/benchmarks/cases.py @@ -18,3 +18,25 @@ def normal_case(length): def worst_case(length): return numpy.random.rand(length) + + +def records_case(length, width, axis): + """Deterministic repeated records with the selected sequence axis.""" + distinct = max(1, length // 2) + codes = numpy.arange(length, dtype=int) % distinct + offsets = numpy.arange(width, dtype=int) + records = codes[:, None] * (width + 1) + offsets[None, :] + return records if axis == 0 else records.T + + +def whole_slice_mask(length, width, axis, case): + """Build a uniform per-record mask for multidimensional partials.""" + if case == "Unmasked": + gaps = numpy.zeros(length, dtype=bool) + elif case == "Gapped": + gaps = numpy.arange(length) % 5 == 0 + else: + gaps = numpy.ones(length, dtype=bool) + + mask = numpy.broadcast_to(gaps[:, None], (length, width)).copy() + return mask if axis == 0 else mask.T diff --git a/docs/fundamentals/order/intervals_chain/index.md b/docs/fundamentals/order/intervals_chain/index.md index 32ec0ee4..21edbbb8 100644 --- a/docs/fundamentals/order/intervals_chain/index.md +++ b/docs/fundamentals/order/intervals_chain/index.md @@ -7,6 +7,75 @@ that restores by _intervals chain_ a sequence with the same with the original se The idea of _intervals chain_ is easy to explain by a concrete example: +## Slice elements along an axis + +For a multidimensional array, an explicit `axis` makes each complete +orthogonal slice on that axis one sequence element. The intervals chain has +one scalar value per selected-axis position, so it is always one-dimensional +and can be passed directly to `foapy.intervals_tuple`. + +``` py linenums="1" +import numpy as np +import foapy + +source = np.array([[1, 2], [3, 4], [1, 2], [5, 6], [1, 2]]) +chain = foapy.intervals_chain( + source, + foapy.binding.start, + foapy.chain_mode.boundary, + axis=0, +) +print(chain) # [1 2 2 4 2] + +intervals = foapy.intervals_tuple( + chain, foapy.binding.start, foapy.tuple_mode.normal +) +print(intervals) # [1 2 2 4 2] +``` + +Here the five rows form the sequence. Selecting columns with `axis=1` +applies the same rule in the column coordinate system. Multidimensional input +without an explicit axis remains invalid. + +## Transform collections of interval chains + +`intervals_chain` always returns one chain, but several chains can also be +stored in a multidimensional array. For `intervals_tuple`, `axis` identifies +the one-dimensional chain lanes and every coordinate on the other dimensions +is processed independently, like `numpy.apply_along_axis`. + +``` py linenums="1" +import numpy as np +import foapy + +chains = np.array([[1, 1, 1, 1], [1, 2, 3, 4]]) +result = foapy.intervals_tuple( + chains, + foapy.binding.start, + foapy.tuple_mode.lossy, + axis=1, +) +print(result) +# [[1 1 1] +# [-- -- --]] +``` + +Tuple modes can produce different lengths for different lanes. A +multidimensional call therefore returns a masked array whose selected axis is +long enough for the longest result. Each result starts at index zero and its +unused trailing positions are masked. These masks are structural padding, +not gaps in a partial sequence. One-dimensional calls continue to return a +plain array. + +For input shape `(A, B, C)`, the result dimension `L` replaces the selected +axis: + +| Selection | Processed lanes | Result shape | +|---|---|---| +| `axis=0` | `chains[:, b, c]` | `(L, B, C)` | +| `axis=1` | `chains[a, :, c]` | `(A, L, C)` | +| `axis=2` | `chains[a, b, :]` | `(A, B, L)` | + === "From a sequence" diff --git a/docs/fundamentals/order/intervals_distribution/index.md b/docs/fundamentals/order/intervals_distribution/index.md index 105a978a..f00bb294 100644 --- a/docs/fundamentals/order/intervals_distribution/index.md +++ b/docs/fundamentals/order/intervals_distribution/index.md @@ -2,6 +2,53 @@ An _intervals distribution_ is an n-tuple of natural numbers where the index represents the interval length and the value is a count of its appearances in the _interval chain_. +## Distribute tuple lanes along an axis + +For a multidimensional collection of interval tuples, `axis` identifies each +independent one-dimensional lane. The distribution dimension replaces that +axis using the same placement rule as `numpy.apply_along_axis`. + +``` py linenums="1" +import numpy as np +import foapy + +tuples = np.array([[1, 1, 3, 1], [1, 2, 1, 3]]) +result = foapy.intervals_distribution(tuples, axis=1) +print(result) +# [[3 0 1] +# [2 1 1]] +``` + +Different lanes can have different maximum interval values and therefore +different distribution lengths. Multidimensional calls return a masked array +sized to the longest distribution. Shorter distributions receive trailing +masks, while a zero between observed interval values remains an ordinary, +unmasked zero-frequency count. + +Masked tuple padding is ignored, so axis-aware operations compose directly: + +``` py linenums="1" +import numpy as np +import foapy + +chains = np.array([[1, 1, 1, 1], [1, 2, 3, 4]]) +tuples = foapy.intervals_tuple( + chains, + foapy.binding.start, + foapy.tuple_mode.lossy, + axis=1, +) +distributions = foapy.intervals_distribution(tuples, axis=1) +print(distributions) +# [[3] +# [--]] +``` + +For a three-dimensional input `(A, B, C)`, axes 0, 1, and 2 produce +distribution shapes `(L, B, C)`, `(A, L, C)`, and `(A, B, L)`. A +one-dimensional input still returns a plain array; only multidimensional +calls return masked arrays. + === "From an interval chain" diff --git a/docs/fundamentals/partials_and_congenerics/intervals_chain/index.md b/docs/fundamentals/partials_and_congenerics/intervals_chain/index.md index e8e1615e..d305be99 100644 --- a/docs/fundamentals/partials_and_congenerics/intervals_chain/index.md +++ b/docs/fundamentals/partials_and_congenerics/intervals_chain/index.md @@ -4,6 +4,84 @@ _Partial Intervals Chain_ is an [_Intervals chain_](../../order/intervals_chain/ The idea of _partial intervals chain_ is easy to explain by a concrete example: +## Slice elements and gaps along an axis + +An explicit `axis` treats each complete orthogonal slice as one sequence +element, matching partial alphabet and order. Every slice must be wholly +present or wholly masked. A wholly masked slice is a gap: it remains masked in +the one-dimensional chain and still counts as a position when distances are +measured. + +``` py linenums="1" +import numpy.ma as ma +import foapy + +source = ma.masked_array( + [[1, 2], [9, 9], [3, 4], [1, 2]], + mask=[[0, 0], [1, 1], [0, 0], [0, 0]], +) +chain = foapy.partials.intervals_chain( + source, + foapy.binding.start, + foapy.chain_mode.boundary, + axis=0, +) +print(chain) # [1 -- 3 3] + +intervals = foapy.partials.intervals_tuple( + chain, foapy.binding.start, foapy.tuple_mode.normal +) +print(intervals) # [1 3 3] +``` + +The masked second row separates the two equal rows by three selected-axis +positions. This call produces one flat chain, because +`partials.intervals_chain` treats complete orthogonal slices as sequence +elements. + +## Collections of partial interval chains + +When an array already contains several partial interval chains, the `axis` +of `foapy.partials.intervals_tuple` selects the one-dimensional chain +direction. All orthogonal coordinates identify independent lanes, like +`numpy.apply_along_axis`: + +``` py linenums="1" +import numpy.ma as ma +import foapy + +chains = ma.masked_array( + [[1, 0, 3, 3, 0, 6], [0, 2, 1, 4, 2, 0]], + mask=[[0, 1, 0, 0, 1, 0], [1, 0, 0, 0, 0, 1]], +) +tuples = foapy.partials.intervals_tuple( + chains, + foapy.binding.start, + foapy.tuple_mode.lossy, + axis=1, +) +print(tuples) +# [[3 --] +# [1 2]] +``` + +Each input mask is a source gap while its lane is calculated. It still counts +toward real positions and the full lane length used by lossy and redundant +modes. The tuple operation then removes source gaps. Masks in the +multidimensional result above mean only that a shorter lane was padded; they +are not aligned source gaps. + +The output dimension replaces the selected input axis. For shape +`(A, B, C)` and longest result length `L`, axes 0, 1, and 2 produce +`(L, B, C)`, `(A, L, C)`, and `(A, B, L)`. Even when every lane has the +same result length, multidimensional output is a masked array with a false +mask. One-dimensional input continues to return a plain array. + +With no gaps, partial tuple values and masks match +`foapy.core.intervals_tuple`. With gaps, the partial function differs only +because it retains the original source coordinates during its lane +calculation. + === "From a partial sequence" diff --git a/docs/fundamentals/partials_and_congenerics/intervals_distribution.md b/docs/fundamentals/partials_and_congenerics/intervals_distribution.md index 94031a29..bc169a15 100644 --- a/docs/fundamentals/partials_and_congenerics/intervals_distribution.md +++ b/docs/fundamentals/partials_and_congenerics/intervals_distribution.md @@ -2,6 +2,36 @@ A _Partial intervals distribution_ is an [_Interval distribution_](../order/intervals_distribution/index.md) produced from [_Partial intervals chain_](./intervals_chain/index.md) by counting all _non-empty_ elements (intervals) in distribution +Use the existing `foapy.intervals_distribution` function for both core and +partial tuples; there is no separate partials distribution API. It accepts +masked arrays, ignores masked positions, and processes independent lanes +along the same `axis` used for a multidimensional partial tuple: + +``` py linenums="1" +import numpy.ma as ma +import foapy + +chains = ma.masked_array( + [[1, 0, 3, 3, 0, 6], [0, 2, 1, 4, 2, 0]], + mask=[[0, 1, 0, 0, 1, 0], [1, 0, 0, 0, 0, 1]], +) +tuples = foapy.partials.intervals_tuple( + chains, + foapy.binding.start, + foapy.tuple_mode.lossy, + axis=1, +) +distribution = foapy.intervals_distribution(tuples, axis=1) +print(distribution) +# [[0 0 1] +# [1 1 --]] +``` + +The mask in `tuples` is structural padding added after each source lane's gaps +have been removed. It contributes no counts. An unmasked zero within a +distribution remains a real zero-frequency bin; only bins beyond a shorter +lane's maximum are masked. + === "From a partial interval chain" diff --git a/docs/references/partials/intervals_tuple.md b/docs/references/partials/intervals_tuple.md index c4f24a56..c808b96c 100644 --- a/docs/references/partials/intervals_tuple.md +++ b/docs/references/partials/intervals_tuple.md @@ -1,2 +1,8 @@ # foapy.partials.intervals_tuple + +Transform one partial interval chain directly, or select an axis containing +independent chains in a multidimensional masked array. For distributions of +the resulting tuples, use `foapy.intervals_distribution` with the same axis; +there is no duplicate partials distribution API. + ::: foapy.partials.intervals_tuple diff --git a/openspec/changes/refactor-congeneric-characteristics/.openspec.yaml b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/.openspec.yaml similarity index 50% rename from openspec/changes/refactor-congeneric-characteristics/.openspec.yaml rename to openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/.openspec.yaml index 2e24cfa4..1ea7e36f 100644 --- a/openspec/changes/refactor-congeneric-characteristics/.openspec.yaml +++ b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/.openspec.yaml @@ -1,2 +1,2 @@ schema: spec-driven -created: 2026-09-07 +created: 2026-09-09 diff --git a/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/design.md b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/design.md new file mode 100644 index 00000000..65c477a8 --- /dev/null +++ b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/design.md @@ -0,0 +1,115 @@ +## Context + +`foapy.core.intervals_chain` now converts multidimensional source slices into one one-dimensional interval chain, while core `intervals_tuple` and `intervals_distribution` remain documented as one-dimensional consumers. Separately, users can hold many already-built chains or tuples in an N-dimensional rectangular array and need to transform every one-dimensional lane along a selected axis. + +`foapy.partials.intervals_tuple` is also one-dimensional. It differs from the core operation because a masked position is a source gap: normal and lossy modes remove gaps, while redundant mode measures complementary intervals against the full source-domain length, including gaps. Once a partial tuple lane is produced, the existing core and top-level `intervals_distribution` semantics already apply because source gaps have been removed and any remaining masks are structural padding. + +The existing tuple modes can produce different result lengths for equal-length input lanes: normal yields `n`, lossy yields `n - k`, and redundant yields `n + k`. Distributions similarly end at each lane's maximum interval value. A normal ndarray therefore cannot preserve every independent result without either rejecting common inputs or introducing a padding convention. The congeneric APIs already demonstrate packed results, but their row-per-symbol meaning is domain-specific and must not be changed by this feature. + +## Goals / Non-Goals + +**Goals:** + +- Apply core interval-tuple and distribution operations independently to one-dimensional lanes along any explicit valid axis. +- Apply partial interval-tuple operations independently to masked lanes without losing gap-aware source positions, and compose their masked output with the existing interval-distribution API. +- Place the variable result dimension at the selected axis position for one-, two-, and three-dimensional inputs. +- Preserve variable lane lengths using trailing masks rather than broadcasting, object arrays, or sentinel values. +- Preserve the direct plain-array behavior of all one-dimensional calls. +- Establish a non-public, axis-aware interval-chain validation hook before defining full semantic validity rules. +- Keep provisional validation off duplicate input-preparation and multidimensional traversal paths. +- Use only C-backed NumPy batch operations in production; Python loops, comprehensions, generator expressions, and disguised loop wrappers are prohibited. +- Cover the behavior in tests, documentation, and deterministic ASV benchmarks. + +**Non-Goals:** + +- Changing congeneric APIs or adding new partial or top-level exports. +- Treating complete orthogonal slices as single equality elements; that is the axis model of `order` and `intervals_chain`, not this independent-lane transformation. +- Returning ragged object arrays or Python lists. +- Defining substantive interval-chain validity rules in this change. +- Changing binding, tuple-mode, interval-value, or one-dimensional ordering semantics. + +## Decisions + +### 1. Axis selects independent one-dimensional lanes + +For an input with shape `(A, B, C)`, axis 0 processes every `input[:, b, c]`, axis 1 processes every `input[a, :, c]`, and axis 2 processes every `input[a, b, :]`. A lane's result replaces the selected dimension, producing `(L, B, C)`, `(A, L, C)`, or `(A, B, L)` when every lane result has length `L`. + +This matches `numpy.apply_along_axis` iteration and dimension placement. Reusing the slice-as-element model from `order` was rejected because tuple and distribution inputs contain scalar interval values; an orthogonal vector is a collection of separate chains, not one interval value. + +### 2. Pack variable results into masked arrays + +Every multidimensional call returns a `numpy.ma.MaskedArray` of `numpy.intp`. The selected output dimension is the maximum result length across all lanes. Each one-dimensional result is copied to the beginning of its lane in its existing order and all trailing positions are masked. If every lane result is empty, the selected result dimension is zero. + +Multidimensional calls always return a masked array, including normal tuple mode and batches whose lane lengths happen to match, so return type never depends on data values. One-dimensional calls always return plain ndarrays. Zero padding without masks was rejected because zero is not an interval value and would require sentinel-aware consumers. Object arrays were rejected because they lose ordinary NumPy shape, dtype, and axis behavior. + +When there are no lanes because an orthogonal dimension is zero, normal tuple mode retains its known selected-axis length; variable-length tuple modes and distribution use selected-axis result length zero because no lane result exists from which to derive a maximum. + +### 3. Use vectorized shared axis dispatch around batch kernels + +The existing one-dimensional kernels remain authoritative for direct calls. Multidimensional calls use vectorized batch kernels instead: a shared helper normalizes the axis once, moves it last, reshapes all orthogonal coordinates into a two-dimensional lane matrix, invokes one batch kernel, reshapes its fixed rectangular masked result, and restores the selected-axis position. The helper preserves masked-array lanes so partial tuple calculations can distinguish gaps from present interval values. + +Variable-length packing uses vectorized selection counts, cumulative destination indices, and NumPy advanced assignment into a masked rectangular output. Core and partial tuple modes compute selection masks and complementary values across the complete lane matrix. Distribution uses indexed NumPy accumulation across all valid lane values. Python loops, comprehensions, generator expressions, `numpy.vectorize`, and `numpy.apply_along_axis` were rejected because they execute lane callbacks in Python rather than processing the batch in compiled NumPy operations. + +### 4. Preserve the one-dimensional fast and compatibility path + +After validating binding and mode values, each core or partial public function prepares its input dimensionality and axis once. A one-dimensional input with omitted axis, axis 0, or axis -1 calls the applicable private one-dimensional kernel directly and returns its legacy plain result. The core tuple path consults its validation hook using that prepared one-dimensional array and does not repeat conversion or axis normalization. Multidimensional input without axis and scalar input raise `Not1DArrayException`; invalid axes use NumPy's axis error through the shared normalization helper. + +This avoids masked allocation and iteration overhead for existing callers and gives the currently documented one-dimensional-only contracts explicit multidimensional validation. + +### 5. Add an internal axis-aware validation seam + +A function named `is_valid_intervals_chain(chain, *, axis=None)` will live in a private core module and will not be imported by `foapy.core` or top-level `foapy`. Its private one-dimensional content check returns the Python Boolean `True` in this change. When called directly with a multidimensional input and explicit axis, it applies that leaf check to every lane and returns `all(...)` as one Boolean. Structural dimensionality and axis errors follow the shared normalization rules. An already prepared one-dimensional ndarray with omitted axis takes a fast path that does not reconvert the input or invoke the general axis normalizer. + +`intervals_tuple` calls the validator before transforming a one-dimensional input and raises `ValueError` if it reports false. For multidimensional input, the dispatcher calls the same hook once on the prepared two-dimensional lane batch before invoking the vectorized tuple kernel. A false aggregate result raises `ValueError` before transformation begins. Although the provisional implementation accepts all structurally valid lanes, future checks must evaluate the complete batch with vectorized NumPy operations. Exporting the helper now was rejected because its long-term semantic contract is deliberately unfinished. + +### 6. Distributions consume masked tuple padding without losing real zeros + +The distribution one-dimensional kernel compresses masked input before counting. Masked tuple positions contribute nothing. Within each returned distribution, zero counts between observed interval values remain ordinary unmasked zeros. Only bins beyond that lane's maximum observed interval are padding and therefore masked in the multidimensional packed result. + +This lets `intervals_distribution(intervals_tuple(chains, ..., axis=a), axis=a)` compose directly. A one-dimensional masked tuple also excludes masks and returns a plain ndarray, while existing plain one-dimensional inputs remain unchanged. + +### 7. Partial tuple lanes retain source-gap coordinates before structural packing + +Each selected-axis lane passed to the partial tuple kernel retains its original length and mask. The kernel compresses gaps only as part of its established tuple-mode semantics. In particular, redundant mode uses the selected-axis lane length and original unmasked indices, so gaps continue to affect boundary and complementary distances independently in every lane. + +After a lane is transformed, its output has no source-gap positions. Any masks in the multidimensional packed result are therefore structural trailing padding only. Every multidimensional partial tuple call returns a masked array, even when all lanes have equal output lengths; one-dimensional calls return the existing plain ndarray. + +Reusing the core tuple kernel was rejected because compression would discard the real source positions required by partial lossy and redundant modes. Treating masks as aligned output gaps was also rejected because tuple transformation deliberately removes source gaps. + +### 8. Partial tuple output composes with the existing distribution API + +A partial interval tuple contains ordinary interval values after per-lane gap removal. The existing `foapy.intervals_distribution` and `foapy.core.intervals_distribution` functions therefore remain the sole distribution APIs. They accept the masked output of a multidimensional partial tuple, exclude its structural padding, and preserve meaningful unmasked zero-frequency bins. + +For dense inputs, the partial tuple-to-distribution pipeline matches the core pipeline. For gapped inputs, differences arise only in the partial tuple values calculated from real source positions; the existing distribution API then counts those values normally. Adding a duplicate `foapy.partials.intervals_distribution` entry point was rejected because it would have identical behavior and no partial-specific state to preserve. + +### 9. Test shapes, masks, dispatch, and composition + +Tests will use explicit valid core and partial chains to verify row and column processing, every axis of three-dimensional inputs, negative axes, every tuple mode, varying and uniform result lengths, mask placement, empty lanes, and one-dimensional direct dispatch. Partial tests will include gaps at different lane positions and verify that redundant results use the full selected-axis domain independently per lane. Distribution tests will distinguish real internal zero counts from masked trailing bins and verify that the existing distribution API directly consumes core and partial axis-aware tuple results. Monkeypatch dispatch tests will prove one-dimensional calls do not enter multidimensional packing and multidimensional core tuple calls validate one prepared lane batch before invoking one batch kernel. An AST regression test will reject Python iteration constructs and disguised loop wrappers in the axis transformation production modules. + +Benchmarks will retain existing one-dimensional matrices and add deterministic multidimensional core and partial tuple lane matrices for time and peak memory. Documentation will show the apply-along-axis shape rule, partial gap semantics, existing distribution composition, and masked variable-length examples. + +### 10. Dense one-dimensional partial chains reuse the core kernel + +A plain one-dimensional input has no gap coordinates to preserve, so its partial interval chain is numerically identical to the core interval chain for every binding and chain mode. After the public partial function validates its arguments and any explicit sole axis, it delegates that dense case to the private core one-dimensional kernel and wraps the result as a fully unmasked `numpy.ma.MaskedArray`. + +Masked inputs continue to use the partial kernel even when their current mask contains no gaps. Inspecting a full mask merely to select a faster implementation would add another linear pass and weaken the benefit for the common plain-array benchmark path. Duplicating the core algorithm was rejected because it would leave two dense implementations to maintain. + +## Risks / Trade-offs + +- **[Vectorized packing allocates lane-wide index arrays]** → Keep all intermediate arrays linear in the lane matrix size and benchmark representative lane counts and lengths. +- **[Masked outputs add allocation cost even for uniform multidimensional results]** → Accept the predictable return contract and preserve an allocation-free direct one-dimensional path. +- **[Masked padding may be mistaken for partial-sequence gaps]** → Document that masks in core multidimensional tuple/distribution outputs are structural trailing padding, not source positions. +- **[Partial tuple input gaps and output padding use the same mask representation]** → Keep masks on input lanes until the partial kernel completes, then document that masks on packed tuple results are structural padding only. +- **[Future validation can reject inputs accepted by the provisional hook]** → Keep the function non-public and describe the current always-true leaf behavior explicitly. +- **[Batch validation reports only aggregate failure]** → Preserve the existing Boolean validation seam and fail before invoking the batch kernel; richer diagnostics remain a future validator concern. +- **[Future substantive validation can again dominate short tuple operations]** → Reuse prepared lane data and fuse validation with values already calculated by tuple kernels where practical. +- **[Empty orthogonal dimensions provide no result shape sample]** → Define deterministic mode-specific empty shapes and cover them with tests. +- **[The dense fast path changes the internal representation of an all-false mask]** → Require the public masked-array type and `numpy.ma.getmaskarray()` semantics, not a particular scalar-versus-array mask storage detail. + +## Migration Plan + +The change is additive for documented one-dimensional usage. Add the validation and lane-packing helpers, refactor existing algorithms into one-dimensional kernels, then enable core and partial public axis dispatch, documentation, tests, and benchmarks. Rollback consists of removing axis dispatch and helpers; all pre-existing one-dimensional signatures remain call-compatible because `axis` is keyword-only. + +## Open Questions + +None. Variable-length outputs use trailing masks, multidimensional calls always return masked arrays, and validation remains internal and permissive for this change. diff --git a/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/proposal.md b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/proposal.md new file mode 100644 index 00000000..bfd5aa36 --- /dev/null +++ b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/proposal.md @@ -0,0 +1,36 @@ +## Why + +Axis-aware interval chains can represent multidimensional source slices, but core and partial tuple APIs do not consistently process collections of independent interval chains stored along an arbitrary axis. Partial pipelines additionally need to preserve gap-aware tuple semantics while gaining the same axis placement and variable-length packing behavior as core pipelines. Because the one-dimensional tuple operations can complete in only a few microseconds, provisional validation must not repeat input conversion, axis normalization, or multidimensional lane traversal before the actual transformation. + +## What Changes + +- Add a keyword-only `axis=None` parameter to core `intervals_tuple` and `intervals_distribution`. +- Add the same keyword-only axis support to `foapy.partials.intervals_tuple`, preserving each masked lane's gap-aware tuple semantics. +- Ensure the masked output of axis-aware `foapy.partials.intervals_tuple` composes directly with the existing top-level and core `intervals_distribution`. +- For multidimensional input, apply the existing one-dimensional operation independently to every lane along the selected axis, following `numpy.apply_along_axis` placement rules. +- Return a masked array for every multidimensional call, replacing the selected axis with the longest lane result and masking the trailing positions of shorter results. +- Keep one-dimensional calls, including explicit `axis=0` and `axis=-1`, on the existing direct path and returning plain arrays. +- Reject multidimensional input without an explicit axis, scalars, and invalid axes consistently with the other axis-aware core APIs. +- Add an internal, non-public `is_valid_intervals_chain(..., axis=None)` hook used by `intervals_tuple`; its provisional one-dimensional validity check always returns `True` so stronger validation can be introduced later without changing the tuple API. Reuse prepared arrays and normalized axes, and validate a complete multidimensional lane batch once before invoking its vectorized tuple kernel. +- Implement multidimensional validation, tuple transformation, distribution counting, and variable-length packing with C-backed NumPy batch operations and no Python iteration in production code. +- Teach axis-aware distributions to ignore masked padding while retaining meaningful zero-frequency bins. +- Keep dense one-dimensional partial interval-chain calls off mask extraction and compression by reusing the equivalent core kernel while preserving the masked-array return contract. +- Add tests, documentation, and ASV time and peak-memory coverage for core and partial one-, two-, and three-dimensional inputs and variable-length lane results. + +## Capabilities + +### New Capabilities + +- `axis-aware-interval-transformations`: Define independent-lane axis processing, shape placement, masked variable-length results, validation integration, and compatibility for core interval tuples and distributions. + +### Modified Capabilities + +- `partials-package`: Extend the partial interval tuple contract with axis-aware independent lanes and composition with the existing interval distribution API. + +## Impact + +- Public APIs: core and top-level `intervals_tuple` and `intervals_distribution` gain keyword-only `axis=None` support, and `foapy.partials.intervals_tuple` gains the same axis parameter. +- Core and partial implementation: tuple and distribution logic shares vectorized axis dispatch and masked result packing while retaining direct one-dimensional kernels and partial gap-aware tuple calculations. Core tuple validation reuses input preparation and validates a prepared lane batch once. +- Internal API: a non-exported interval-chain validation helper is introduced and called before each applicable one-dimensional tuple transformation. +- Tests, docstrings, API references, fundamental documentation, and ASV benchmarks require multidimensional core and partial coverage. +- Congeneric APIs, dependencies, public exports, and existing one-dimensional results remain unchanged. diff --git a/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/specs/axis-aware-interval-transformations/spec.md b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/specs/axis-aware-interval-transformations/spec.md new file mode 100644 index 00000000..0d711d52 --- /dev/null +++ b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/specs/axis-aware-interval-transformations/spec.md @@ -0,0 +1,199 @@ +## ADDED Requirements + +### Requirement: Interval tuples process independent lanes along an axis +The system MUST provide `foapy.core.intervals_tuple(chain, binding, tuple_mode, *, axis=None)`. When `chain` is multidimensional and `axis` is an integer, every one-dimensional lane obtained by fixing all coordinates outside that axis MUST be processed independently with the existing one-dimensional interval-tuple semantics. The lane result dimensions MUST replace the selected input axis in the same position as `numpy.apply_along_axis`. + +#### Scenario: Rows are independent chains +- **WHEN** `intervals_tuple()` receives `[[1, 1, 3, 1], [1, 2, 1, 3]]` with start binding, lossy mode, and `axis=1` +- **THEN** it processes the two rows independently and returns values equivalent to `[[1, 1], [1, 3]]` + +#### Scenario: Columns are independent chains +- **WHEN** the same two chains are stored as columns of a shape `(4, 2)` input and `axis=0` is selected +- **THEN** the result values are arranged with the tuple-result dimension at axis 0 + +#### Scenario: Three-dimensional lanes preserve axis placement +- **WHEN** input has shape `(A, B, C)` and each lane result has length `L` +- **THEN** axes 0, 1, and 2 produce result shapes `(L, B, C)`, `(A, L, C)`, and `(A, B, L)` respectively + +### Requirement: Multidimensional interval tuples preserve variable result lengths with masks +Every multidimensional `intervals_tuple()` call MUST return a `numpy.ma.MaskedArray`, even when all lane results have the same length. The selected output axis MUST have the maximum result length across all lanes. Each lane result MUST be packed from index zero in its existing one-dimensional output order, and positions after that lane's result MUST be masked. Data stored under padding masks MUST NOT be treated as interval values. + +#### Scenario: Lossy lane lengths differ +- **WHEN** start-boundary chains `[1, 1, 1, 1]` and `[1, 2, 3, 4]` are processed in lossy mode along the row axis +- **THEN** the first result is `[1, 1, 1]`, the second result is empty, the selected output axis has length three, and all three positions of the second row are masked + +#### Scenario: Redundant lane lengths differ +- **WHEN** the same chains are processed in redundant mode along the row axis +- **THEN** their results have lengths five and eight, the selected output axis has length eight, and the last three positions of the first row are masked + +#### Scenario: Uniform lane lengths still return a masked array +- **WHEN** every multidimensional lane produces the same tuple length +- **THEN** the result is a masked array with an entirely false mask rather than a plain ndarray + +#### Scenario: All lane results are empty +- **WHEN** every processed lane produces an empty tuple +- **THEN** the selected output axis has length zero and all orthogonal dimensions are preserved + +### Requirement: Interval tuple axis behavior preserves the legacy one-dimensional API +Calls to `intervals_tuple()` with one-dimensional input MUST retain the existing binding, tuple-mode, order, dtype, return type, and empty-input behavior. One-dimensional calls with `axis=0` or `axis=-1` MUST use the direct one-dimensional path and return a plain `numpy.ndarray`. Multidimensional input without an explicit axis MUST raise `Not1DArrayException`; negative axes MUST follow NumPy conventions; an out-of-range axis MUST raise NumPy's axis error; and scalar input MUST raise `Not1DArrayException`. + +#### Scenario: Existing one-dimensional call remains unchanged +- **WHEN** a one-dimensional chain is passed without `axis` for any valid binding and tuple mode +- **THEN** its result is identical to the pre-axis API and remains a plain `numpy.ndarray` + +#### Scenario: Explicit sole axis matches legacy behavior +- **WHEN** a one-dimensional chain is called with `axis=0` or `axis=-1` +- **THEN** both results equal the call that omits `axis` and remain plain arrays + +#### Scenario: Multidimensional input requires an axis +- **WHEN** a multidimensional chain collection is passed without `axis` +- **THEN** `intervals_tuple()` raises `Not1DArrayException` + +#### Scenario: Axis validation is consistent +- **WHEN** an explicit axis is negative, out of range, or applied to scalar input +- **THEN** an equivalent negative axis succeeds, an out-of-range axis raises NumPy's axis error, and scalar input raises `Not1DArrayException` + +### Requirement: Interval tuple uses an efficient provisional internal chain validator +The implementation MUST provide a non-public `is_valid_intervals_chain(chain, *, axis=None)` helper and MUST NOT export it from `foapy.core` or top-level `foapy`. For one-dimensional input, its provisional content-validity check MUST return `True`. When the helper is called directly with multidimensional input and an explicit axis, it MUST treat each one-dimensional lane along that axis as a chain and return one aggregate Boolean that is true only when every lane passes the one-dimensional check. + +`intervals_tuple()` MUST prepare its array representation and normalize its axis no more than once per call. It MUST invoke the validation hook on its prepared one-dimensional input or once on the prepared multidimensional lane matrix before applying the corresponding tuple kernel. Multidimensional validation, transformation, distribution, and variable-length packing MUST use C-backed vectorized NumPy batch operations. Production code MUST NOT use Python loops, comprehensions, generator expressions, `numpy.vectorize`, or `numpy.apply_along_axis`. When the hook reports an invalid lane batch, `intervals_tuple()` MUST raise `ValueError` before transformation begins. + +#### Scenario: Provisional one-dimensional validation succeeds +- **WHEN** the internal validator receives any structurally accepted one-dimensional input +- **THEN** it returns the Python Boolean `True` + +#### Scenario: Prepared one-dimensional validation stays on the fast path +- **WHEN** `intervals_tuple()` receives a prepared one-dimensional array with omitted or already normalized axis +- **THEN** it consults the validation hook without repeating array conversion or general axis normalization before calling the one-dimensional tuple kernel + +#### Scenario: Multidimensional validation aggregates lanes +- **WHEN** the internal validator receives multidimensional input with an explicit valid axis +- **THEN** it applies the provisional check to every lane and returns a single Python Boolean + +#### Scenario: Tuple transformation validates a multidimensional batch once +- **WHEN** `intervals_tuple()` processes multidimensional input with an explicit valid axis +- **THEN** the prepared lane matrix is validated once before one vectorized batch tuple kernel runs + +#### Scenario: Production axis transformations contain no Python iteration +- **WHEN** the axis transformation, core tuple, core distribution, validation, and partial tuple modules are inspected +- **THEN** they contain no `for`, `while`, comprehension, generator expression, `numpy.vectorize`, or `numpy.apply_along_axis` implementation path + +#### Scenario: Validator remains internal +- **WHEN** callers inspect the public members of `foapy` and `foapy.core` +- **THEN** `is_valid_intervals_chain` is not exported + +#### Scenario: Tuple transformation consults validation +- **WHEN** the internal validation hook reports `False` for a one-dimensional input or multidimensional lane batch +- **THEN** `intervals_tuple()` raises `ValueError` before applying a tuple mode and returns no partial result + +### Requirement: Interval distributions process independent lanes along an axis +The system MUST provide `foapy.core.intervals_distribution(tuple_result, *, axis=None)`. For multidimensional input with an explicit axis, every one-dimensional lane along that axis MUST be distributed independently, and the distribution dimension MUST replace the selected axis. Masked tuple positions MUST be excluded before counting. The result MUST be a `numpy.ma.MaskedArray` whose selected axis has the maximum distribution length across all lanes; shorter distributions MUST be packed from index zero and trailing positions MUST be masked. + +#### Scenario: Row distributions +- **WHEN** tuple rows are `[1, 1, 3, 1]` and `[1, 2, 1, 3]` and `axis=1` is selected +- **THEN** the result values are `[[3, 0, 1], [2, 1, 1]]` with the distribution dimension at axis 1 + +#### Scenario: Masked tuple padding is excluded +- **WHEN** a tuple lane contains masked trailing positions +- **THEN** those positions contribute no counts to its distribution + +#### Scenario: Zero-frequency bins remain meaningful +- **WHEN** a lane contains interval values one and three but no value two +- **THEN** its distribution contains an unmasked zero at the value-two bin while only positions beyond the lane's distribution length are masked + +#### Scenario: Distribution lengths differ +- **WHEN** multidimensional tuple lanes have different maximum interval values +- **THEN** the selected result axis uses the greatest maximum and positions beyond each shorter lane's maximum are masked + +#### Scenario: Empty distribution lanes +- **WHEN** one lane contains no unmasked interval values +- **THEN** its positions are masked across the shared distribution axis, and when every lane is empty the selected output axis has length zero + +### Requirement: Interval distribution axis behavior preserves the legacy one-dimensional API +Calls to `intervals_distribution()` with plain one-dimensional input MUST preserve existing counts, `numpy.intp` dtype, empty-input behavior, and plain `numpy.ndarray` return type. One-dimensional calls with `axis=0` or `axis=-1` MUST use the direct path. A one-dimensional masked tuple MUST exclude masked positions and return a plain array. Multidimensional input without an explicit axis, scalar input, and invalid axes MUST follow the same validation rules as axis-aware interval tuples. + +#### Scenario: Existing one-dimensional distribution remains unchanged +- **WHEN** a plain one-dimensional tuple is passed without `axis` +- **THEN** its result is identical to the pre-axis API and remains a plain `numpy.ndarray` + +#### Scenario: One-dimensional masked tuple is compressed for counting +- **WHEN** a one-dimensional tuple contains masked padding +- **THEN** masked positions are excluded and the returned distribution is a plain array + +#### Scenario: Explicit sole axis matches legacy distribution +- **WHEN** a one-dimensional tuple is called with `axis=0` or `axis=-1` +- **THEN** both results equal the call that omits `axis` + +#### Scenario: Invalid distribution dimensionality or axis +- **WHEN** multidimensional input omits `axis`, input is scalar, or an explicit axis is out of range +- **THEN** the function raises `Not1DArrayException`, `Not1DArrayException`, or NumPy's axis error respectively + +### Requirement: Partial interval tuples process masked chains independently along an axis +The system MUST provide `foapy.partials.intervals_tuple(chain, binding, tuple_mode, *, axis=None)`. For multidimensional input with an explicit axis, every one-dimensional lane along that axis MUST be processed independently with the existing partial interval-tuple semantics. Plain lanes MUST be treated as fully unmasked. Masked positions MUST remain source-coordinate gaps while a lane is processed, including when lossy mode identifies boundary values and redundant mode calculates complementary values from the full selected-axis lane length. The lane result dimension MUST replace the selected input axis. + +Every multidimensional call MUST return a `numpy.ma.MaskedArray` of `numpy.intp`, including calls whose lane results have equal lengths. Each gap-free lane result MUST be packed from index zero in its existing output order, and positions after shorter results MUST be masked. Those output masks MUST represent structural padding rather than source gaps. + +#### Scenario: Normal mode removes gaps independently +- **WHEN** masked partial chains are processed in normal mode along their shared chain axis +- **THEN** each lane's masked positions are removed independently and the resulting unequal lengths are packed from index zero with trailing masks + +#### Scenario: Lossy mode uses each lane's real source positions +- **WHEN** different lanes contain gaps at different selected-axis positions and are processed in lossy mode +- **THEN** boundary values are identified from each lane's original unmasked indices rather than indices in its compressed values + +#### Scenario: Redundant mode retains the full lane domain +- **WHEN** a gapped lane is processed in redundant mode +- **THEN** complementary values are calculated against the original selected-axis lane length, including gaps, before its result is structurally packed + +#### Scenario: Dense partial tuples match core +- **WHEN** a multidimensional partial chain collection has no masked positions +- **THEN** its partial tuple values and structural masks equal `foapy.core.intervals_tuple()` for the same binding, tuple mode, and axis + +#### Scenario: Three-dimensional partial lanes preserve axis placement +- **WHEN** partial chain input has shape `(A, B, C)` and the longest lane result has length `L` +- **THEN** axes 0, 1, and 2 produce shapes `(L, B, C)`, `(A, L, C)`, and `(A, B, L)` respectively + +#### Scenario: Empty partial tuple collections +- **WHEN** all selected partial lanes are empty or fully masked, or no lanes exist because an orthogonal dimension is zero +- **THEN** the selected output axis has length zero and every orthogonal dimension is preserved + +### Requirement: Partial interval tuple axis behavior preserves its one-dimensional API +One-dimensional calls to `foapy.partials.intervals_tuple()` with omitted axis, `axis=0`, or `axis=-1` MUST use the direct partial kernel and return the existing plain `numpy.ndarray` result. Existing binding, tuple-mode, gap, source-position, ordering, dtype, and empty-input behavior MUST remain unchanged. Multidimensional input without an explicit axis MUST raise `Not1DArrayException`; scalar input MUST raise `Not1DArrayException`; negative axes MUST follow NumPy conventions; and an out-of-range axis MUST raise NumPy's axis error. + +#### Scenario: Existing gapped one-dimensional tuple remains unchanged +- **WHEN** a one-dimensional masked chain is passed without `axis` for any valid binding and tuple mode +- **THEN** its result is identical to the pre-axis partial API and remains a plain `numpy.ndarray` + +#### Scenario: Explicit sole partial axis matches omitted axis +- **WHEN** a one-dimensional partial chain is called with `axis=0` or `axis=-1` +- **THEN** both results equal the omitted-axis call and remain plain arrays + +#### Scenario: Partial tuple dimensionality and axis validation +- **WHEN** multidimensional input omits `axis`, input is scalar, or an explicit axis is out of range +- **THEN** the function raises `Not1DArrayException`, `Not1DArrayException`, or NumPy's axis error respectively + +### Requirement: Partial tuple output composes with the existing interval distribution +The existing `foapy.intervals_distribution(tuple_result, *, axis=None)` and `foapy.core.intervals_distribution(tuple_result, *, axis=None)` APIs MUST accept the masked multidimensional output of `foapy.partials.intervals_tuple()`. They MUST exclude structural padding before counting each selected-axis lane while preserving meaningful unmasked zero-frequency bins. The system MUST NOT add a duplicate `foapy.partials.intervals_distribution` API. + +#### Scenario: Partial tuple output composes directly with distribution +- **WHEN** the masked multidimensional result of `foapy.partials.intervals_tuple(..., axis=a)` is passed to `foapy.intervals_distribution(..., axis=a)` +- **THEN** structural padding is excluded and every lane's unmasked interval values are counted independently + +#### Scenario: Dense partial pipeline matches core +- **WHEN** dense chain collections are transformed with partial and core interval tuples and their results are passed to the existing distribution API +- **THEN** both pipelines produce distributions with identical values, masks, shape, dtype, and return type + +#### Scenario: No duplicate partial distribution export +- **WHEN** callers inspect public members of `foapy.partials` +- **THEN** no `intervals_distribution` member is added + +### Requirement: Axis-aware interval transformations are documented and benchmarked +The system MUST document independent-lane axis semantics, one-, two-, and three-dimensional shape placement, masked variable-length packing, return-type rules, validation errors, partial gap handling, and core and partial tuple-to-distribution pipelines. The ASV suite MUST include time and peak-memory benchmarks for deterministic core and partial multidimensional tuple inputs across representative lengths, lane counts, axis placements, tuple modes, mask states, and variable-length outputs while retaining legacy one-dimensional coverage. + +#### Scenario: Public references explain axis transformations +- **WHEN** a user opens the generated references for core interval tuples and distributions +- **THEN** they include runnable multidimensional examples and explain how the selected axis is replaced and shorter results are masked + +#### Scenario: ASV discovers axis transformation benchmarks +- **WHEN** the benchmark suite is collected +- **THEN** it includes deterministic time and peak-memory cases for multidimensional core interval tuples and distributions and partial interval tuples diff --git a/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/specs/partials-package/spec.md b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/specs/partials-package/spec.md new file mode 100644 index 00000000..7b877ec9 --- /dev/null +++ b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/specs/partials-package/spec.md @@ -0,0 +1,75 @@ +## MODIFIED Requirements + +### Requirement: Dense partial interval chains avoid gap-processing overhead +The system MUST return a `numpy.ma.MaskedArray` from `foapy.partials.intervals_chain()` for plain one-dimensional input, and its values, dtype, binding behavior, and chain-mode behavior MUST equal `foapy.core.intervals_chain()`. Plain one-dimensional input with omitted axis, `axis=0`, or `axis=-1` MUST reuse the core one-dimensional interval-chain calculation without extracting, compressing, or scattering a mask. Masked input MUST retain the existing gap-aware partial calculation. + +#### Scenario: Dense one-dimensional input uses core-equivalent calculation +- **WHEN** a plain one-dimensional array is passed for any binding and chain mode +- **THEN** the returned masked array has no masked positions and contains the same `numpy.intp` values as the core interval chain + +#### Scenario: Explicit sole axis retains the dense fast path +- **WHEN** plain one-dimensional input is passed with `axis=0` or `axis=-1` +- **THEN** axis validation succeeds and the same core-equivalent calculation is used + +#### Scenario: Gapped input retains partial semantics +- **WHEN** a masked one-dimensional input contains gaps +- **THEN** the gaps remain masked and count toward interval distances through the partial interval-chain calculation + +### Requirement: Partial interval tuple strategies +The system MUST provide `foapy.partials.intervals_tuple(chain, binding, tuple_mode, *, axis=None)`, accepting a one-dimensional masked interval chain (as produced by `foapy.partials.intervals_chain`), a plain fully unmasked chain, or a multidimensional collection of such chains. One-dimensional input with omitted axis, `axis=0`, or `axis=-1` MUST return a plain one-dimensional `numpy.ndarray` of dtype `numpy.intp` with masked gap positions excluded entirely. + +For multidimensional input with an explicit valid axis, every one-dimensional lane along that axis MUST be processed independently. Each lane's masked positions MUST retain their source coordinates while boundary and complementary values are calculated, then MUST be excluded from that lane's tuple result. The system MUST return a `numpy.ma.MaskedArray` of dtype `numpy.intp` with the result dimension replacing the selected input axis, each lane result packed from index zero, and positions after shorter results masked as structural padding. For `binding.end`, each returned lane's order MUST match `foapy.core.intervals_tuple`'s reversed processing frame rather than source left-to-right order. + +#### Scenario: Normal mode compresses gaps out +- **WHEN** `intervals_tuple()` is called with `tuple_mode.normal` on a one-dimensional chain containing masked positions +- **THEN** it returns a plain `ndarray` containing only the non-masked chain values, in their original relative order, with no masked positions represented + +#### Scenario: Lossy mode drops boundary and gap values together +- **WHEN** `intervals_tuple()` is called with `tuple_mode.lossy` +- **THEN** each processed lane contains only the interior non-boundary values — gap positions and boundary first-occurrence positions are both absent from the lane result +- **AND** for an unmasked chain, the result is identical to `foapy.core.intervals_tuple(chain, binding, tuple_mode.lossy)`, including element order for both `binding.start` and `binding.end` + +#### Scenario: Redundant mode appends gap-aware complementary values +- **WHEN** `intervals_tuple()` is called with `tuple_mode.redundant` on a chain containing masked positions +- **THEN** each processed lane contains the compressed gap-free chain values followed by one trailing complementary value per inferred unique symbol +- **AND** each trailing value is computed as the distance from that symbol's last occurrence to the edge of the true lane domain, including gaps, not the compressed element count + +#### Scenario: Redundant mode keeps a single consistent order for binding.end +- **WHEN** `intervals_tuple()` is called with `tuple_mode.redundant` and `binding.end` on a chain containing masked positions +- **THEN** both the compressed portion and the trailing portion of each result are expressed in the same reversed processing frame — the compressed portion is not left in original source order while the trailing portion is computed in the reversed frame + +#### Scenario: Empty or fully masked one-dimensional input yields an empty array +- **WHEN** `intervals_tuple()` receives an empty one-dimensional chain or a one-dimensional chain that is fully masked, for any `tuple_mode` +- **THEN** it returns `numpy.array([], dtype=numpy.intp)` as a plain array + +#### Scenario: Multidimensional lanes are independent +- **WHEN** a multidimensional masked array is passed with an explicit axis +- **THEN** every lane obtained by fixing all orthogonal coordinates is processed independently with the existing partial tuple-mode semantics + +#### Scenario: Multidimensional variable lengths use structural masks +- **WHEN** partial lanes produce unequal result lengths +- **THEN** the selected result axis has the longest lane length, every lane is packed from index zero, and only trailing positions after shorter lane results are masked + +#### Scenario: Uniform multidimensional lengths remain masked +- **WHEN** every multidimensional lane produces the same tuple length +- **THEN** the result remains a `numpy.ma.MaskedArray` with an entirely false mask + +#### Scenario: Three-dimensional axis placement +- **WHEN** input has shape `(A, B, C)` and the longest lane result has length `L` +- **THEN** axes 0, 1, and 2 produce result shapes `(L, B, C)`, `(A, L, C)`, and `(A, B, L)` respectively + +#### Scenario: Dense multidimensional input matches core +- **WHEN** a multidimensional partial chain collection has no masked positions +- **THEN** its values and structural masks equal `foapy.core.intervals_tuple()` for the same binding, tuple mode, and axis + +#### Scenario: Empty multidimensional lane collections +- **WHEN** all selected lanes are empty or fully masked, or no lanes exist because an orthogonal dimension is zero +- **THEN** the selected output axis has length zero and every orthogonal dimension is preserved + +#### Scenario: Axis validation and dimensionality errors +- **WHEN** a multidimensional input omits `axis`, input is scalar, an equivalent negative axis is used, or an explicit axis is out of range +- **THEN** the function respectively raises `Not1DArrayException`, raises `Not1DArrayException`, succeeds with the same result as the positive axis, or raises NumPy's axis error + +#### Scenario: Invalid binding or tuple mode +- **WHEN** `intervals_tuple()` receives an unsupported binding or tuple mode +- **THEN** it raises `ValueError` diff --git a/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/tasks.md b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/tasks.md new file mode 100644 index 00000000..b1fa2d55 --- /dev/null +++ b/openspec/changes/add-axis-support-to-intervals-tuple-and-distribution/tasks.md @@ -0,0 +1,120 @@ +## 1. Shared Axis Infrastructure and Validation + +- [x] 1.1 Add or extract a private lane-application helper that normalizes an axis, presents all one-dimensional lanes as one batch, packs variable-length `numpy.intp` results from index zero, masks trailing positions, and restores the result dimension at the selected axis. +- [x] 1.2 Define deterministic behavior for empty selected axes and absent lanes caused by structurally empty orthogonal dimensions. +- [x] 1.3 Add the internal non-exported `is_valid_intervals_chain(chain, *, axis=None)` helper with an always-true one-dimensional content check and aggregate multidimensional lane validation. +- [x] 1.4 Verify the validator's scalar, missing-axis, negative-axis, and invalid-axis behavior uses the shared normalization contract. +- [x] 1.5 Add an export regression test proving the provisional validator is absent from both `foapy.core` and top-level `foapy`. + +## 2. Axis-Aware Interval Tuples + +- [x] 2.1 Extract or preserve private one-dimensional normal, lossy, and redundant tuple kernels without changing their binding, order, dtype, or empty-input semantics. +- [x] 2.2 Add typed keyword-only `axis=None` support to `foapy.core.intervals_tuple` and keep one-dimensional omitted-axis, `axis=0`, and `axis=-1` calls on the direct plain-array path. +- [x] 2.3 Invoke the internal interval-chain validator before tuple transformation and raise `ValueError` when it reports false. +- [x] 2.4 Dispatch multidimensional inputs across independent lanes and always return a masked array with the output dimension replacing the selected input axis. +- [x] 2.5 Preserve every lane's existing output order while masking trailing positions for unequal lossy and redundant result lengths. +- [x] 2.6 Preserve binding and tuple-mode validation order and implement specified multidimensional-without-axis, scalar, and invalid-axis errors. + +## 3. Axis-Aware Interval Distributions + +- [x] 3.1 Extract or preserve a private one-dimensional distribution kernel with legacy plain-array counts, dtype, and empty-input behavior. +- [x] 3.2 Add typed keyword-only `axis=None` support to `foapy.core.intervals_distribution` and retain the direct plain-array path for one-dimensional input. +- [x] 3.3 Accept one-dimensional masked tuple input by excluding masked positions before counting while preserving meaningful unmasked zero-frequency bins. +- [x] 3.4 Dispatch multidimensional inputs across independent lanes and always return a masked array with shorter distributions trailing-masked along the selected axis. +- [x] 3.5 Support negative axes and implement specified missing-axis, scalar, invalid-axis, empty-lane, and structurally empty behavior. +- [x] 3.6 Verify direct composition of axis-aware `intervals_tuple` output into `intervals_distribution` on the same axis. + +## 4. Interval Tuple Tests + +- [x] 4.1 Add exact row- and column-lane tests for normal, lossy, and redundant modes with both bindings. +- [x] 4.2 Add three-dimensional tests for every valid axis and equivalent negative axes, checking that the output dimension replaces the selected axis. +- [x] 4.3 Add unequal-length tests checking packed values and trailing masks, plus equal-length tests proving multidimensional results remain masked arrays with false masks. +- [x] 4.4 Add one-dimensional compatibility and dispatch tests for omitted axis, axis 0, axis -1, empty input, dtype, and plain-array return type. +- [x] 4.5 Add missing-axis, invalid-axis, scalar, empty selected-axis, and structurally empty orthogonal-dimension tests. +- [x] 4.6 Add validator integration tests proving tuple transformation consults the hook and rejects a monkeypatched false result. + +## 5. Interval Distribution Tests + +- [x] 5.1 Add exact row- and column-lane distribution tests and three-dimensional shape-placement tests for every valid axis. +- [x] 5.2 Add unequal-maximum tests distinguishing masked trailing bins from valid unmasked zero-frequency bins. +- [x] 5.3 Add masked-input, fully masked lane, all-empty, empty selected-axis, and structurally empty orthogonal-dimension tests. +- [x] 5.4 Add one-dimensional parity and direct-dispatch tests for omitted, positive, and negative sole-axis calls, including plain return type and `numpy.intp` dtype. +- [x] 5.5 Add missing-axis, invalid-axis, and scalar validation tests. +- [x] 5.6 Add multidimensional tuple-to-distribution pipeline tests across tuple modes and representative axes. + +## 6. Documentation + +- [x] 6.1 Update the core `intervals_tuple` signature, annotations, return types, axis errors, and docstring with runnable two- and three-dimensional lane examples and variable-length masks. +- [x] 6.2 Update the core `intervals_distribution` signature, annotations, masked-input rules, return types, axis errors, and docstring with runnable multidimensional examples. +- [x] 6.3 Update fundamental documentation with the independent-lane model, `(A, B, C)` axis-to-shape mapping, structural-mask semantics, and tuple-to-distribution composition. +- [x] 6.4 Build the MkDocs site and resolve reference or example failures. + +## 7. Benchmarks + +- [x] 7.1 Extend the interval-tuple ASV module with deterministic multidimensional lane cases across representative lane lengths, lane counts, axis placements, bindings, tuple modes, and uniform or variable result lengths. +- [x] 7.2 Extend the interval-distribution ASV module with deterministic plain and masked multidimensional cases across representative lane lengths, lane counts, and axis placements. +- [x] 7.3 Add time and peak-memory methods with practical quick-mode skips and timeouts while retaining existing one-dimensional benchmark matrices. +- [x] 7.4 Verify ASV discovery and smoke-run the new benchmark cases without changing stored machine-specific results. + +## 8. Core Final Verification + +- [x] 8.1 Run the existing one-dimensional tuple, distribution, pipeline, partial, and congeneric suites to confirm compatibility. +- [x] 8.2 Run the complete pytest suite and resolve regressions. +- [x] 8.3 Run Black, isort, flake8, and `git diff --check` on the completed change. +- [x] 8.4 Run strict OpenSpec validation and confirm every proposal requirement is represented by implementation and verification tasks. + +## 9. Axis-Aware Partial Interval Tuples + +- [x] 9.1 Extract the existing partial tuple body into an authoritative one-dimensional kernel that retains each lane's original mask, unmasked indices, and full source-domain length while calculating normal, lossy, and redundant results. +- [x] 9.2 Add typed keyword-only `axis=None` support to `foapy.partials.intervals_tuple` and keep omitted-axis, `axis=0`, and `axis=-1` one-dimensional calls on the direct plain-array path. +- [x] 9.3 Reuse the shared lane dispatcher for multidimensional partial chains while preserving masked lanes until the partial kernel finishes and always returning structurally packed masked arrays. +- [x] 9.4 Implement negative-axis, missing-axis, scalar, invalid-axis, empty selected-axis, fully masked lane, and structurally empty orthogonal-dimension behavior. +- [x] 9.5 Preserve existing binding and tuple-mode validation order, `numpy.intp` dtype, start/end processing frames, and dense parity with the core tuple API. + +## 10. Partial Interval Tuple Tests + +- [x] 10.1 Add exact row- and column-lane tests for normal, lossy, and redundant modes with both bindings and lane-specific gap positions. +- [x] 10.2 Add three-dimensional tests for every valid axis and equivalent negative axes, checking selected-axis result placement. +- [x] 10.3 Add variable- and uniform-length tests checking structural masks, plus empty, fully masked, and absent-lane shape cases. +- [x] 10.4 Add one-dimensional compatibility and direct-dispatch tests for omitted axis, axis 0, axis -1, plain or masked input, every mode, dtype, order, and empty input. +- [x] 10.5 Add dimensionality and invalid-axis tests and multidimensional dense-parity tests against `foapy.core.intervals_tuple`. +- [x] 10.6 Add gapped partial tuple-to-distribution pipeline tests proving the existing `foapy.intervals_distribution` accepts masked output across tuple modes, bindings, and representative axes. + +## 11. Partial Documentation and Benchmarks + +- [x] 11.1 Update partial tuple annotations and docstring with axis behavior, input-gap versus output-padding masks, errors, return types, and runnable two- and three-dimensional examples. +- [x] 11.2 Update the partial tuple API reference and fundamental documentation with lane processing, selected-axis shape mapping, gap-aware calculations, structural packing, and dense core parity. +- [x] 11.3 Document direct composition with the existing core and top-level interval distribution APIs and confirm that no duplicate partial distribution API is exported. +- [x] 11.4 Add deterministic ASV time and peak-memory matrices for multidimensional partial tuples across lane lengths, lane counts, axes, bindings, tuple modes, and gap patterns. +- [x] 11.5 Build the MkDocs site, verify ASV discovery, and smoke-run the new partial benchmark cases without saving machine-specific results. + +## 12. Final Verification + +- [x] 12.1 Run existing partial one-dimensional and pipeline suites to confirm compatibility. +- [x] 12.2 Run the new partial axis suite and complete pytest suite and resolve regressions. +- [x] 12.3 Run Black, isort, flake8, and `git diff --check` on the completed delta. +- [x] 12.4 Run strict OpenSpec validation after the deferred `partials-package` delta spec is generated, and confirm every partial requirement is represented by implementation and verification tasks. + +## 13. Validation-Path Performance Follow-Up + +- [x] 13.1 Add a fast path to `is_valid_intervals_chain()` for already prepared one-dimensional ndarrays with omitted axis while preserving direct ArrayLike conversion and all scalar, missing-axis, negative-axis, and invalid-axis behavior. +- [x] 13.2 Refactor core `intervals_tuple()` to prepare its input once, normalize any explicit one-dimensional axis once, consult the validation hook, and reuse the prepared array in the one-dimensional tuple kernel. +- [x] 13.3 Remove duplicate multidimensional input preparation and axis normalization from validation while preserving `ValueError` behavior and preventing partial results from escaping. +- [x] 13.4 Extend validator integration and dispatch tests to verify the one-dimensional fast path, validation-before-transformation order, false-result rejection, and absence of duplicate multidimensional preparation. +- [x] 13.5 Re-run the affected legacy one-dimensional and multidimensional ASV tuple benchmarks and confirm validation dispatch no longer creates a material regression against the pre-axis implementation. +- [x] 13.6 Run the focused tuple tests, complete pytest suite, Black, isort, flake8, `git diff --check`, and strict OpenSpec validation. + +## 14. Vectorized Lane Processing Follow-Up + +- [x] 14.1 Amend mandatory repository guidance to prohibit Python loops, comprehensions, generator expressions, and disguised loop wrappers in production while allowing loops in tests and benchmark setup. +- [x] 14.2 Replace the shared per-lane callback and packing loops with one vectorized batch-dispatch call and NumPy indexed masked packing. +- [x] 14.3 Add vectorized multidimensional kernels for core interval tuples and interval distributions, including aggregate pre-transform validation without duplicate axis normalization. +- [x] 14.4 Add vectorized multidimensional partial interval-tuple kernels that preserve gap coordinates, binding order, variable lengths, and structural masks. +- [x] 14.5 Update dispatch tests for batch validation and add an AST regression test prohibiting Python iteration and disguised loop wrappers in the affected production modules. +- [x] 14.6 Run focused axis and pipeline tests, the complete pytest suite, ASV tuple benchmarks, formatting and lint checks, `git diff --check`, and strict OpenSpec validation. + +## 15. Dense Partial Interval-Chain Performance Follow-Up + +- [x] 15.1 Route plain one-dimensional partial interval-chain inputs through the core one-dimensional kernel while preserving argument validation, explicit sole-axis behavior, `numpy.intp` values, and the masked-array return contract. +- [x] 15.2 Add dispatch and parity tests for omitted, positive, and negative sole-axis calls while retaining masked gap-aware coverage. +- [x] 15.3 Re-run the focused partial interval-chain suites and the 10,000-element dense benchmark, then run the complete pytest suite, formatting and lint checks, `git diff --check`, and strict OpenSpec validation. diff --git a/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/.openspec.yaml b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/.openspec.yaml new file mode 100644 index 00000000..7a8e2be6 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/design.md b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/design.md new file mode 100644 index 00000000..645b1633 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/design.md @@ -0,0 +1,125 @@ +## Context + +Core `alphabet` and `order` currently accept only one-dimensional arrays and implement the same stable factorization separately: alphabet values are unique scalar elements ordered by first appearance, and order values are the inverse indices into that alphabet. Partials compress masked positions, delegate factorization to core, and scatter order values back into a masked one-dimensional result. + +The new model generalizes a sequence position from a scalar to a complete orthogonal slice. For input shape `(d0, ..., da, ..., dn)` and sequence axis `a`, there are `da` sequence elements, each with shape `(d0, ..., d(a-1), d(a+1), ..., dn)`. The order therefore remains one-dimensional with length `da`; the alphabet retains the input rank and replaces `da` with the number of unique slices. + +The core coupling is expressed by the dense reconstruction invariant: + +```python +order_result, alphabet_result = order(X, return_alphabet=True, axis=a) +restored = np.take(alphabet_result, order_result, axis=a) +``` + +`restored` must equal `X` exactly. + +## Goals / Non-Goals + +**Goals:** + +- Give core and partials alphabet/order one shared, stable factorization model for scalar and slice elements. +- Keep order one-dimensional regardless of input rank. +- Preserve the alphabet axis so `numpy.take` is the inverse operation for dense inputs. +- Preserve existing one-dimensional calls and positional arguments. +- Define partial gaps at the same sequence-position granularity as dense slice elements. +- Cover multidimensional behavior, validation, reconstruction, documentation, and performance with tests and benchmarks. + +**Non-Goals:** + +- Axis support for interval-chain, interval-tuple, distribution, congeneric, or characteristic functions. +- Applying the one-dimensional function independently to every row or column as `numpy.apply_along_axis` does. +- Ragged batches, padded alphabets, or masked padding. +- Flattening multidimensional input when `axis` is omitted. +- Representing a sequence element whose internal scalar components are only partially present. + +## Decisions + +### Axis selects a sequence of complete orthogonal slices + +Normalize an explicit axis and conceptually move it to the front: + +```python +elements = np.moveaxis(X, axis, 0) +``` + +`elements[i]` is one indivisible alphabet element. Equality means equality of the complete slices, not independent element-wise factorization along multiple one-dimensional lines. + +This follows NumPy's record-axis model used by `numpy.unique(..., axis=axis)` and directly supports the reconstruction invariant. The rejected alternative was apply-along-axis semantics, which would produce one order per orthogonal coordinate and ragged alphabets requiring padding. + +### A shared private factorization primitive drives multidimensional calls + +Implement one internal routine that returns both the one-dimensional inverse order and the stable alphabet for multidimensional slice elements. `core.alphabet` selects the alphabet result; `core.order` selects the order and optionally returns the same alphabet. This prevents differences in equality, stable ordering, empty handling, dtype, or axis placement between the coupled multidimensional APIs. + +Legacy one-dimensional calls retain their dedicated algorithms. `alphabet` computes only its stable unique values, while `order` materializes the alphabet only when `return_alphabet=True`. Partials likewise retain direct one-dimensional compression and scattering, including an early return for empty or fully masked inputs. This avoids imposing multidimensional bookkeeping or an eagerly computed companion result on the established hot paths. Tests enforce that 1-D inputs, including explicit `axis=0` and `axis=-1`, do not enter the multidimensional helpers. + +The routine will move the selected axis to the front for comparison, flatten only the orthogonal dimensions into record fields, group equal records with stable first-occurrence bookkeeping, select original slices for the alphabet, and move the alphabet axis back to its original position. Sorting/grouping must not leak sorted order into the public alphabet. + +Using `numpy.apply_along_axis` was rejected because it expresses independent sequences rather than slice elements. Returning `numpy.unique(..., axis=axis)` directly was rejected because its alphabet is sorted rather than ordered by first appearance; it may still inform or support the internal grouping algorithm if its indices and inverse are remapped to stable order without narrowing supported dtypes. + +### Hashes identify candidate groups, never element identity + +For multidimensional elements, compute an XXH3-128 digest of each contiguous flattened slice and factorize the fixed-width digests. NumPy's `apply_along_axis` dispatches the compiled `xxhash.xxh3_128_digest` implementation over the byte-record rows, avoiding an explicit Python record loop in the factorizer. Equal digest groups are checked against adjacent original slices in one vectorized operation. If a group contains unequal slices, fall back to the exact record-based factorizer for the complete input; a digest collision therefore cannot change public results. Dtypes whose equality cannot be represented safely by the byte normalization also use the exact path directly. + +Floating-point signed zeros are normalized before hashing because NumPy considers positive and negative zero equal although their byte representations differ. NaNs remain subject to exact slice comparison and therefore retain the existing record-factorization behavior. The experiment uses digests only as compact candidate indices, not as cryptographic guarantees or equality definitions. Sorting, remapping, and collision verification remain vectorized NumPy operations. Hash dispatch uses `apply_along_axis`, whose internal iteration invokes one compiled XXH3 call per record; this trades Python call overhead for a mature, well-distributed non-cryptographic hash and avoids the large per-position weight arrays used by the previous `einsum` prototype. + +### Axis is keyword-only and omission preserves the legacy contract + +Use these signatures: + +```python +alphabet(X, *, axis=None) +order(X, return_alphabet=False, *, axis=None) +``` + +and equivalent signatures in `foapy.partials`. Keeping `return_alphabet` in its current positional slot preserves calls such as `order(X, True)`. Making the new parameter keyword-only avoids interpreting existing boolean arguments as axes. + +When `axis is None`, require a one-dimensional input and use axis 0 internally. This preserves current results and multidimensional `Not1DArrayException` behavior. An explicit axis opts into slice elements; negative axes are normalized through NumPy's axis utilities. Zero-dimensional inputs are not sequences and are rejected. + +`axis=None` flattening was rejected because it silently changes the existing multidimensional error contract and destroys the distinction between structured slice elements and scalar elements. + +### The alphabet retains rank and axis placement; order does not + +For input shape `(d0, ..., da, ..., dn)` with `k` unique slice elements: + +```text +order.shape = (da,) +alphabet.shape = (d0, ..., k, ..., dn) +``` + +Keeping the alphabet axis at `a` makes `np.take(alphabet, order, axis=a)` work uniformly for axis 0, interior axes, and the last axis. Moving the alphabet axis to the front in the public result was rejected because callers would need additional axis metadata or a second move operation to reconstruct the source. + +### Partial masks describe whole missing sequence positions + +After moving the sequence axis to the front, each slice mask must be uniform: all false means a present slice element and all true means a gap. Mixed masks are rejected with `ValueError`, because a one-dimensional order mask cannot faithfully state that only part of an indivisible element exists and a plain ndarray alphabet cannot preserve component masks. + +Partials factorize only present slices through the shared core primitive, scatter their inverse indices into a one-dimensional buffer, and use the whole-slice gap vector as the result mask. The partial alphabet remains a plain ndarray and excludes gaps. For non-gap inputs it has exact parity with core. + +Allowing partially masked slice fields was rejected because it would require a masked alphabet, define nontrivial record equality for missing fields, and weaken the simple order/alphabet reconstruction model. Treating any partially masked slice as a total gap was rejected because it silently discards observed data. + +### Reconstruction for partials is defined over observed slices + +For a nonempty partial alphabet, callers can take with the order's underlying or filled indices and then broadcast the one-dimensional order mask across all orthogonal dimensions. The resulting masked array reconstructs every observed slice and the original whole-slice gap pattern; data stored beneath gaps is intentionally not part of the partial-sequence value. + +The documentation will provide a helper-sized example rather than claim that bare `numpy.take` propagates a masked index array across every component of a slice. + +## Risks / Trade-offs + +- **Slice comparison can allocate a large temporary view or index arrays** → Move axes as views where possible, flatten only for comparison, select alphabet values from the original array, and benchmark time and peak memory across axis placements and record widths. +- **NumPy record uniqueness has dtype-specific limitations** → Preserve the current sortable-value contract, test the currently documented numeric and string inputs, and avoid relying on a narrower helper without a compatible fallback or an explicit error. +- **Mixed masks may be useful as structured records in the future** → Reject them clearly now; a later capability can define masked fields and a masked-alphabet return type without changing whole-slice gap semantics. +- **A keyword-only axis differs from some NumPy signatures** → It protects the established positional `return_alphabet` API and all examples still use the familiar `axis=` spelling. +- **Empty orthogonal dimensions make every non-gap slice structurally empty** → Define and test grouping explicitly so multiple empty slices compare consistently and still satisfy reconstruction shapes. + +## Migration Plan + +1. Introduce the shared private dense factorization primitive for multidimensional calls while retaining dedicated one-dimensional paths and their tests. +2. Add explicit-axis behavior and reconstruction tests to core alphabet/order. +3. Extend partials mask validation, compression, scattering, and alphabet-axis restoration. +4. Update API documentation and benchmarks. +5. Run the focused suites followed by the full test and documentation checks. + +Rollback consists of reverting the optional keyword and generalized helper paths; existing callers require no migration because omitted-axis behavior remains unchanged. + +## Open Questions + +None. The proposal fixes the axis meaning, output shapes, stable order, reconstruction rule, default behavior, and partial mask granularity. diff --git a/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/proposal.md b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/proposal.md new file mode 100644 index 00000000..62dc7209 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/proposal.md @@ -0,0 +1,32 @@ +## Why + +`foapy.core.alphabet` and `foapy.core.order` currently reject multidimensional inputs even though an array axis can naturally define the sequence direction and each orthogonal slice can act as one alphabet element. Extending the same factorization model to dense and partial sequences allows structured elements while preserving the defining ability to reconstruct the source from its order and alphabet. + +## What Changes + +- Add an optional `axis` parameter to `foapy.core.alphabet` and `foapy.core.order`. +- When `axis` is supplied, treat each complete slice indexed along that axis as one sequence element, preserve first-appearance ordering, return a one-dimensional order, and retain the selected axis in the alphabet result. +- Define reconstruction through `numpy.take(alphabet, order, axis=axis)` for dense inputs. +- Add equivalent axis-aware behavior to `foapy.partials.alphabet` and `foapy.partials.order`, excluding fully masked slice positions as gaps and preserving those gaps in the one-dimensional order mask. +- Preserve the existing public behavior for one-dimensional calls that omit `axis`. +- Add tests, documentation, type annotations, and benchmarks for one-, two-, and three-dimensional inputs, all valid positive and negative axes, stable slice ordering, reconstruction, and partial-sequence gaps. + +## Capabilities + +### New Capabilities + +- `axis-aware-sequence-factorization`: Stable core alphabet/order factorization of dense arrays whose sequence elements are complete orthogonal slices along a selected axis. + +### Modified Capabilities + +- `partials-package`: Extend partial alphabet/order operations from scalar elements in one-dimensional inputs to slice elements along an explicit axis while preserving whole-slice gaps. + +## Impact + +- Public APIs: `foapy.core.alphabet`, `foapy.core.order`, `foapy.partials.alphabet`, and `foapy.partials.order` gain an optional axis argument. +- Implementation: core stable-uniqueness and inverse-mapping logic must compare complete slices and preserve the selected axis in alphabet output; partials must derive a one-dimensional positional mask from slice masks. +- Tests and benchmarks: existing one-dimensional coverage remains, with new multidimensional, axis-validation, reconstruction, and masked-slice cases. +- Documentation: core and partials API references will describe slice-as-element semantics, shapes, axis placement, and reconstruction. +- Dependencies: `xxhash>=2.0.0` is added for compiled XXH3-128 candidate + digests; NumPy still provides axis handling, sorting, exact verification, and + remapping. diff --git a/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/specs/axis-aware-sequence-factorization/spec.md b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/specs/axis-aware-sequence-factorization/spec.md new file mode 100644 index 00000000..6425cb43 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/specs/axis-aware-sequence-factorization/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: Core alphabet supports slice elements along an axis +The system MUST provide `foapy.core.alphabet(X, *, axis=None)`. When `axis` is an integer, the function MUST treat the complete orthogonal slice at each position along that axis as one sequence element, return unique slice elements in order of first appearance, preserve the input rank, and replace only the selected axis length with the alphabet size. Both positive and equivalent negative axes MUST produce the same result. + +#### Scenario: One-dimensional scalar elements +- **WHEN** `alphabet(['b', 'a', 'b', 'c'], axis=0)` is called +- **THEN** it returns `['b', 'a', 'c']` + +#### Scenario: Rows are elements along axis zero +- **WHEN** a two-dimensional input has rows `R0`, `R1`, `R0` and `alphabet(X, axis=0)` is called +- **THEN** it returns the two-row array `R0`, `R1` with shape `(2, X.shape[1])` + +#### Scenario: Columns are elements along axis one +- **WHEN** a two-dimensional input has columns `C0`, `C1`, `C0`, `C2` and `alphabet(X, axis=1)` is called +- **THEN** it returns the three columns `C0`, `C1`, `C2` in that order and preserves axis 1 as the alphabet axis + +#### Scenario: Planes are elements in a three-dimensional input +- **WHEN** `alphabet()` receives a three-dimensional input and an explicit valid axis +- **THEN** each complete two-dimensional slice indexed along that axis is compared as one element and the result retains all orthogonal dimensions unchanged + +#### Scenario: First appearance controls alphabet order +- **WHEN** distinct slice elements have a lexicographic or numeric sort order different from their first occurrence order +- **THEN** the returned alphabet follows first occurrence order rather than sorted order + +### Requirement: Core order is the one-dimensional inverse of the alphabet +The system MUST provide `foapy.core.order(X, return_alphabet=False, *, axis=None)`. For an explicit axis, the returned order MUST be a one-dimensional `numpy.intp` array of length `X.shape[axis]`, and each value MUST be the zero-based first-appearance alphabet index of the complete slice at that axis position. When `return_alphabet=True`, the accompanying alphabet MUST equal `foapy.core.alphabet(X, axis=axis)`, and `numpy.take(alphabet, order, axis=axis)` MUST reconstruct the original dense input exactly. + +#### Scenario: Repeated rows produce repeated order indices +- **WHEN** a two-dimensional input has rows `R0`, `R1`, `R0` and `order(X, axis=0)` is called +- **THEN** the order is `[0, 1, 0]` + +#### Scenario: Repeated columns produce repeated order indices +- **WHEN** a two-dimensional input has columns `C0`, `C1`, `C0`, `C2` and `order(X, axis=1)` is called +- **THEN** the order is `[0, 1, 0, 2]` + +#### Scenario: Return alphabet uses the shared factorization +- **WHEN** `order(X, return_alphabet=True, axis=axis)` is called +- **THEN** it returns the same one-dimensional order as `order(X, axis=axis)` and the same alphabet as `alphabet(X, axis=axis)` + +#### Scenario: Dense reconstruction +- **WHEN** an order and alphabet are returned for any supported dense input and valid axis +- **THEN** `numpy.take(alphabet, order, axis=axis)` equals the original input in shape, dtype, and values + +#### Scenario: Empty sequence axis +- **WHEN** the selected input axis has length zero +- **THEN** order returns an empty `numpy.intp` array and alphabet returns an array whose selected axis has length zero and whose other dimensions match the input + +### Requirement: Axis behavior preserves the legacy one-dimensional API +Calls that omit `axis` MUST retain the existing one-dimensional behavior and return types of `foapy.core.alphabet` and `foapy.core.order`. A multidimensional input without an explicit axis MUST continue to raise `Not1DArrayException`; an explicit axis MUST be normalized using NumPy axis conventions, including negative axes, and an out-of-range axis MUST raise NumPy's axis error. Scalar inputs MUST be rejected as non-sequences. + +#### Scenario: Existing call without axis remains valid +- **WHEN** an existing caller invokes `alphabet(X)` or `order(X, return_alphabet)` with a one-dimensional input +- **THEN** the result is unchanged from the pre-axis API + +#### Scenario: Multidimensional input requires explicit intent +- **WHEN** a multidimensional input is passed without `axis` +- **THEN** the function raises `Not1DArrayException` + +#### Scenario: Negative axis is equivalent +- **WHEN** `axis=-1` and its equivalent positive axis select the same dimension +- **THEN** both calls return identical orders and alphabets + +#### Scenario: Axis is out of range +- **WHEN** an explicit axis is outside the input dimensionality +- **THEN** the function raises NumPy's axis error + +#### Scenario: Scalar input is rejected +- **WHEN** `alphabet()` or `order()` receives a zero-dimensional input +- **THEN** it raises `Not1DArrayException` diff --git a/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/specs/partials-package/spec.md b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/specs/partials-package/spec.md new file mode 100644 index 00000000..830aa442 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/specs/partials-package/spec.md @@ -0,0 +1,94 @@ +## MODIFIED Requirements + +### Requirement: Partial sequence ordering +The system MUST provide `foapy.partials.order(X, return_alphabet=False, *, axis=None)`, accepting a masked array or a plain sequence treated as fully unmasked. With no axis, it MUST retain its existing one-dimensional behavior. With an explicit axis, each complete orthogonal slice MUST be one sequence element, a fully masked slice MUST be a gap, and every slice MUST be either wholly masked or wholly unmasked. The result MUST be a one-dimensional masked integer array of length `X.shape[axis]`; non-gap positions MUST contain zero-based alphabet indices in first-appearance order and gap positions MUST remain masked. When requested, the alphabet MUST be a plain array of the unique non-gap slices, retain the selected axis, and equal `foapy.partials.alphabet(X, axis=axis)`. The public API documentation and annotations MUST describe both call modes, shapes, return forms, mask rules, and reconstruction of observed slices. + +#### Scenario: One-dimensional order preserves gaps +- **WHEN** `order()` receives `['a', --, 'b', 'a', --]` +- **THEN** it returns `[0, --, 1, 0, --]` with the same length and mask + +#### Scenario: Whole masked slices are gaps +- **WHEN** a multidimensional partial input contains slice `S0`, a wholly masked slice, `S1`, and `S0` along the selected axis +- **THEN** order returns `[0, --, 1, 0]` + +#### Scenario: Order returns an axis-preserving alphabet when requested +- **WHEN** multidimensional `order()` is called with `return_alphabet=True` and an explicit axis +- **THEN** it returns the one-dimensional masked order and a plain alphabet array containing only unique non-gap slices in first-appearance order, with the alphabet dimension at the selected axis + +#### Scenario: Observed slices can be reconstructed +- **WHEN** the returned alphabet is indexed along the selected axis by the unmasked order values +- **THEN** every non-gap source slice is reconstructed exactly and broadcasting the order mask across the orthogonal dimensions restores the source gap mask + +#### Scenario: Empty or fully masked input +- **WHEN** `order()` receives an empty or fully masked input with a valid sequence axis +- **THEN** it returns a same-axis-length fully masked order array and an axis-preserving empty alphabet when requested + +#### Scenario: Mixed mask inside one slice is rejected +- **WHEN** any slice along the selected axis contains both masked and unmasked scalar components +- **THEN** `order()` raises `ValueError` because the slice does not define one present or absent sequence element + +#### Scenario: Multidimensional input without axis is rejected +- **WHEN** `order()` receives an input with more than one dimension and no explicit axis +- **THEN** it raises `Not1DArrayException` + +#### Scenario: Public documentation provides runnable examples +- **WHEN** a user opens the generated reference for `foapy.partials.order` +- **THEN** the reference includes runnable examples for plain input, one-dimensional gaps, multidimensional whole-slice gaps, `return_alphabet=True`, and reconstruction + +#### Scenario: Signature annotations describe the contract +- **WHEN** a caller inspects `foapy.partials.order` +- **THEN** annotations identify the accepted array-like input, boolean `return_alphabet` flag, optional integer axis, and masked-array or tuple return forms + +#### Scenario: ASV benchmark coverage exists +- **WHEN** the ASV benchmark suite discovers partials benchmarks +- **THEN** it includes time and peak-memory cases for `foapy.partials.order` across scalable sequence-axis lengths and representative one-dimensional, multidimensional, unmasked, partially gapped, and fully gapped data + +### Requirement: Partial sequence alphabet extraction +The system MUST provide `foapy.partials.alphabet(X, *, axis=None)`, accepting a masked array or plain sequence. With no axis, it MUST retain its existing one-dimensional behavior. With an explicit axis, each complete orthogonal slice MUST be one sequence element, fully masked slices MUST be excluded as gaps, and every slice MUST be either wholly masked or wholly unmasked. The function MUST return a plain `numpy.ndarray` containing unique non-gap slices in first-appearance order, preserving the input rank and replacing only the selected axis length with the alphabet size. Its public documentation and annotations MUST describe masked-slice exclusion, axis and shape behavior, empty and fully masked inputs, validation errors, and runnable examples. + +#### Scenario: One-dimensional masked values are excluded +- **WHEN** `alphabet()` receives `['a', --, 'b', 'a', --]` +- **THEN** it returns `['a', 'b']` as a plain one-dimensional array + +#### Scenario: Fully masked multidimensional slices are excluded +- **WHEN** slices along the selected axis are `S0`, a wholly masked slice, `S1`, and `S0` +- **THEN** `alphabet()` returns `S0`, `S1` as a plain array with the selected axis reduced to length two + +#### Scenario: First slice is masked +- **WHEN** the first slice is wholly masked and later non-gap slices are `S1`, `S0` +- **THEN** the alphabet is `S1`, `S0` and the masked first position does not affect first-appearance order + +#### Scenario: First occurrence is masked and later occurrence is unmasked +- **WHEN** data under a wholly masked slice equals a later non-gap slice +- **THEN** only the later non-gap occurrence introduces that slice into the alphabet + +#### Scenario: Fully masked or empty input +- **WHEN** `alphabet()` receives a fully masked input or an input whose selected axis is empty +- **THEN** it returns a plain array with length zero on the selected axis and all orthogonal dimensions preserved + +#### Scenario: Mixed mask inside one slice is rejected +- **WHEN** any slice along the selected axis contains both masked and unmasked scalar components +- **THEN** `alphabet()` raises `ValueError` + +#### Scenario: Multidimensional input without axis is rejected +- **WHEN** `alphabet()` receives a multidimensional input without an explicit axis +- **THEN** it raises `Not1DArrayException` + +### Requirement: Package boundary and dense parity +The system MUST expose exactly `order`, `alphabet`, `intervals_chain`, and `intervals_tuple` from `foapy.partials`. For one-dimensional inputs and for wholly present slice elements along an explicit axis, partial alphabet/order results MUST match their corresponding core results, subject to the documented masked-order representation. Existing partial interval behavior and top-level package exports MUST remain unchanged. + +#### Scenario: Submodule-only access +- **WHEN** a caller imports `foapy.partials` +- **THEN** the four partials functions are available from that submodule and are not added as top-level `foapy` functions + +#### Scenario: Zero-gap parity for alphabet and order +- **WHEN** a caller uses a plain or fully unmasked input with any valid explicit axis +- **THEN** the partial alphabet equals the core alphabet and the non-masked values of the partial order equal the core order + +#### Scenario: Existing one-dimensional pipeline parity +- **WHEN** a caller uses an unmasked one-dimensional input and valid combinations of binding, chain mode, and tuple mode +- **THEN** partials results match the corresponding core results, subject to the documented masked-array representation + +#### Scenario: Existing interval gap behavior remains unchanged +- **WHEN** a caller passes a one-dimensional partial sequence through ordering, interval-chain, and tuple operations +- **THEN** every source gap retains the behavior documented by the partial interval requirements diff --git a/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/tasks.md b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/tasks.md new file mode 100644 index 00000000..69bf409b --- /dev/null +++ b/openspec/changes/archive/2026-09-08-add-axis-support-to-alphabet-order/tasks.md @@ -0,0 +1,48 @@ +## 1. Core Factorization Contract + +- [x] 1.1 Add core alphabet/order tests for stable scalar behavior, two-dimensional row and column elements, three-dimensional elements on every axis, positive/negative axis equivalence, and `return_alphabet` parity. +- [x] 1.2 Add reconstruction and shape tests asserting that order is one-dimensional, alphabet preserves rank and axis placement, and `numpy.take(alphabet, order, axis=axis)` restores dense inputs. +- [x] 1.3 Add core validation and edge-case tests for omitted axes on multidimensional inputs, out-of-range axes, scalar inputs, empty sequence axes, empty orthogonal dimensions, and representative numeric and string dtypes. +- [x] 1.4 Implement a shared private stable-factorization primitive that compares complete orthogonal slices, returns inverse indices in first-appearance order, preserves alphabet dtype and axis placement, and handles empty shapes. +- [x] 1.5 Extend `foapy.core.alphabet` and `foapy.core.order` with the keyword-only `axis` parameter and delegate both functions to the shared primitive without changing omitted-axis one-dimensional behavior. + +## 2. Partial Sequence Factorization + +- [x] 2.1 Add partial alphabet/order tests for multidimensional plain inputs, fully unmasked arrays, whole-slice gaps on axis 0 and interior/last axes, first-appearance ordering after gaps, positive/negative axes, and core parity. +- [x] 2.2 Add partial reconstruction, empty/fully-gapped input, multidimensional-without-axis, invalid-axis, scalar-input, and mixed-mask rejection tests. +- [x] 2.3 Implement whole-slice mask validation and derive the one-dimensional sequence-position gap mask for any valid explicit axis. +- [x] 2.4 Extend `foapy.partials.alphabet` and `foapy.partials.order` with keyword-only `axis`, factorize present slices through the shared core behavior, preserve the alphabet axis, and scatter inverse indices into the masked one-dimensional order. + +## 3. Documentation and Performance Coverage + +- [x] 3.1 Update core alphabet/order docstrings and references with the axis model, output-shape rules, one-, two-, and three-dimensional examples, negative-axis behavior, errors, and dense `numpy.take` reconstruction. +- [x] 3.2 Update partial alphabet/order docstrings and references with whole-slice gap rules, mixed-mask errors, return shapes, observed-slice reconstruction, and dense parity. +- [x] 3.3 Update type annotations for all four functions and add or adjust ASV time and peak-memory benchmarks across scalable sequence lengths, multiple element shapes, axis placements, and partial gap patterns. + +## 4. Verification + +- [x] 4.1 Run the focused core and partials alphabet/order test suites and resolve all regressions. +- [x] 4.2 Run the complete test suite, documentation build/checks, linting, and relevant benchmark discovery or smoke cases. +- [x] 4.3 Verify the final public signatures, legacy positional `return_alphabet` compatibility, public exports, and every dense/partial reconstruction example from the specifications. + +## 5. One-Dimensional Performance Restoration + +- [x] 5.1 Restore dedicated core one-dimensional paths so `alphabet` does not compute order and `order(..., return_alphabet=False)` does not materialize an alphabet. +- [x] 5.2 Restore dedicated partial one-dimensional paths, including the empty/fully-masked fast path, without invoking multidimensional slice-mask processing. +- [x] 5.3 Add dispatch regression tests and compare representative one-dimensional timings with the pre-axis implementations. + +## 6. Experimental Hash Factorization + +- [x] 6.1 Replace multidimensional record uniqueness with vectorized 128-bit candidate grouping while retaining exact slice comparison and a collision fallback. +- [x] 6.2 Add equality-semantic and forced-collision tests for the hash factorizer, including signed zero and NaN behavior. +- [x] 6.3 Run the complete tests and compare time and peak allocations against the current axis implementation across element widths and cardinalities. + +## 7. XXH3 Hash Experiment + +- [x] 7.1 Replace the custom `einsum` fingerprint with XXH3-128 dispatched through `numpy.apply_along_axis`, add the runtime dependency, and preserve exact collision verification. +- [x] 7.2 Run focused and complete correctness checks, then compare time and peak memory against exact record factorization across element widths and cardinalities. + +## 8. Internal Factorizer Clarity + +- [x] 8.1 Rename the shared helper to `_stable_factorize`, update its private call sites, and document how equal-digest groups are exactly verified before factorization. +- [x] 8.2 Rename the partial shared helper to `_stable_partial_factorize` and update all private call sites and dispatch tests. diff --git a/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/.openspec.yaml b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/.openspec.yaml new file mode 100644 index 00000000..7a8e2be6 --- /dev/null +++ b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/design.md b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/design.md new file mode 100644 index 00000000..0cc2d8c4 --- /dev/null +++ b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/design.md @@ -0,0 +1,78 @@ +## Context + +The core and partial `alphabet` and `order` APIs already define a shared axis model: each complete orthogonal slice at a position on the selected axis is one sequence element, and the resulting order is one-dimensional. `intervals_chain` still contains a separate one-dimensional grouping implementation and rejects multidimensional arrays. The partial version additionally measures distances in the full positional domain so masked gaps count toward every boundary and occurrence distance. + +The change spans the core and partial APIs, their validation behavior, documentation, tests, and benchmarks. It must preserve the optimized legacy one-dimensional paths restored during the preceding axis-factorization work. + +## Goals / Non-Goals + +**Goals:** + +- Apply the established slice-as-element axis semantics to core and partial interval chains. +- Return one scalar interval per selected-axis position as a one-dimensional `numpy.intp` chain. +- Preserve all binding, boundary, cycle, and partial gap-distance semantics in selected-axis coordinates. +- Reuse existing order/factorization and one-dimensional interval logic instead of creating another multidimensional equality implementation. +- Preserve legacy one-dimensional behavior and performance characteristics. +- Cover axis behavior with tests, documentation, and deterministic ASV benchmarks. + +**Non-Goals:** + +- Adding `axis` to `intervals_tuple`; it consumes the one-dimensional chain produced here. +- Changing `foapy.congenerics.intervals_chains`, whose two-dimensional shape has domain-specific row-per-symbol semantics. +- Returning an input-shaped multidimensional interval array. +- Changing supported dtypes, binding values, chain modes, public exports, or dependencies. +- Optimizing the multidimensional factorization machinery beyond what `order` already provides. + +## Decisions + +### 1. Match the existing keyword-only axis contract + +Both functions will expose `intervals_chain(X, binding, chain_mode, *, axis=None)`. An integer axis selects the sequence dimension and each complete orthogonal slice becomes one element. The result is one-dimensional with length `X.shape[axis]`, because a slice represents one sequence position and therefore has one interval value. + +This matches `order`, keeps the output directly consumable by `intervals_tuple`, and avoids an ambiguous broadcast of one interval across every scalar in a slice. Returning an input-shaped array was rejected because it would duplicate values and break the current interval pipeline. + +### 2. Reduce multidimensional equality to order codes + +For multidimensional dense input, core `intervals_chain` will obtain the one-dimensional equality pattern from the established axis-aware order/factorization path, then apply the existing one-dimensional interval algorithm to those integer codes. Interval chains depend only on which sequence positions contain equal elements, not on the original values or the first-appearance labels assigned to equality classes. + +The implementation should extract or reuse a private one-dimensional interval kernel so the public function does not need recursive validation. Directly teaching the interval algorithm to compare arbitrary slices was rejected because it would duplicate dtype, hashing, signed-zero, NaN, collision, and axis-placement logic already centralized in factorization. + +### 3. Keep one-dimensional inputs on the existing direct path + +When the normalized input is one-dimensional, including explicit `axis=0` or `axis=-1`, the function will validate the axis and run the current value-sorting implementation directly. It will not first call multidimensional factorization or `order`. + +This preserves the legacy allocation profile and avoids the extra alphabet/inverse work that previously caused measurable one-dimensional regressions. + +### 4. Reuse partial order semantics for multidimensional masks + +For multidimensional partial input, `foapy.partials.order(X, axis=axis)` will provide a masked one-dimensional equality pattern. It already enforces that each slice is wholly present or wholly masked and excludes masked data from factorization. The existing partial interval kernel will process that masked order while using the full selected-axis length and original gap positions. + +Consequently, gap slices remain masked in the result and count toward distances, including leading/trailing boundary distances and cyclic wrap-around. Implementing separate slice-mask validation inside `intervals_chain` was rejected because it could drift from partial alphabet/order behavior. + +### 5. Preserve validation and error behavior + +Existing binding and chain-mode validation remains authoritative. Axis normalization will reuse the same helper as alphabet/order: multidimensional input without an axis raises `Not1DArrayException`, negative axes normalize normally, out-of-range axes raise NumPy's axis error, and scalar input raises `Not1DArrayException`. Legacy validation order should remain stable where practical. + +### 6. Test through invariants and explicit examples + +Core axis tests will cover rows, columns, all axes of a three-dimensional input, both bindings, both chain modes, negative and invalid axes, empty sequence axes, structurally empty slices, and legacy one-dimensional dispatch. A central invariant is: + +`intervals_chain(X, binding, chain_mode, axis=a) == intervals_chain(order(X, axis=a), binding, chain_mode)`. + +Partial tests will cover plain-input parity with core, whole-slice gaps, gap-aware boundary and cycle distances, mixed-mask rejection, fully masked and empty inputs, and axis validation. Benchmark data will be deterministic and will exercise multiple sequence lengths, slice widths, axis placements, and partial mask states while retaining existing one-dimensional suites. + +## Risks / Trade-offs + +- **[Multidimensional work includes factorization plus interval grouping]** Axis inputs may perform two grouping/sorting stages. → Start with the shared, correctness-oriented design, benchmark it explicitly, and optimize only with evidence while retaining the same contract. +- **[One-dimensional performance regression]** Routing legacy inputs through order would add unnecessary work. → Keep a dedicated direct path and add dispatch tests and benchmark coverage. +- **[Core and partial axis rules drift]** Duplicated normalization or mask handling could produce inconsistent behavior. → Reuse the existing factorization and partial-order helpers and assert dense parity. +- **[Import coupling]** Reusing order from interval modules can introduce accidental package-level cycles. → Import concrete internal modules or lower-level helpers rather than public package initializers. +- **[Noisy benchmark comparisons]** Historical GitHub-runner results can report false regressions. → Use deterministic inputs and interpret base/head performance only when both revisions are measured on the same runner. + +## Migration Plan + +This is backward-compatible and requires no data migration. Add the optional parameter and internal dispatch, then tests, documentation, and benchmarks. Rollback consists of reverting the axis dispatch and related artifacts; existing three-argument callers remain unaffected throughout. + +## Open Questions + +None. The scope includes both core and partial interval chains and intentionally leaves tuple and congeneric APIs unchanged. diff --git a/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/proposal.md b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/proposal.md new file mode 100644 index 00000000..8769ea96 --- /dev/null +++ b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/proposal.md @@ -0,0 +1,30 @@ +## Why + +`alphabet` and `order` can treat complete slices along an explicit axis as sequence elements, but `intervals_chain` still rejects every multidimensional input. Extending the same axis model to dense and partial interval chains completes the sequence-decomposition pipeline without changing existing one-dimensional callers. + +## What Changes + +- Add a keyword-only `axis` parameter to `foapy.core.intervals_chain` and `foapy.partials.intervals_chain`. +- Treat each complete orthogonal slice along an explicit axis as one sequence element and return one interval value per position on that axis. +- Preserve the existing binding and boundary/cycle semantics in the selected axis coordinate system. +- For partial inputs, treat wholly masked slices as gaps that remain masked and count toward interval distances, and reject partially masked slices. +- Preserve legacy one-dimensional behavior, errors, return types, and the direct one-dimensional performance path when `axis` is omitted or explicitly selects the sole axis. +- Add multidimensional tests, public documentation, and ASV time and peak-memory coverage. + +## Capabilities + +### New Capabilities + +- `axis-aware-interval-chains`: Define slice-as-element interval-chain behavior, axis normalization, output shape, binding and chain-mode semantics, and compatibility with the existing one-dimensional core API. + +### Modified Capabilities + +- `partials-package`: Extend partial interval chains with explicit-axis slice elements, whole-slice gaps, mixed-mask validation, and dense parity. + +## Impact + +- Public APIs: `foapy.core.intervals_chain` and `foapy.partials.intervals_chain` gain a keyword-only `axis=None` parameter. +- Core implementation: axis-aware inputs can reuse the established sequence factorization/order semantics before applying the one-dimensional interval kernel. +- Partial implementation: multidimensional mask validation and whole-slice gap handling align with partial `alphabet` and `order`. +- Tests, API docstrings/reference output, fundamental documentation, and ASV benchmark suites require corresponding coverage. +- No new runtime dependency or top-level export is required. diff --git a/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/specs/axis-aware-interval-chains/spec.md b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/specs/axis-aware-interval-chains/spec.md new file mode 100644 index 00000000..50967e62 --- /dev/null +++ b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/specs/axis-aware-interval-chains/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Core interval chains support slice elements along an axis +The system MUST provide `foapy.core.intervals_chain(X, binding, chain_mode, *, axis=None)`. When `axis` is an integer, each complete orthogonal slice indexed along that axis MUST be one sequence element, and the function MUST return a one-dimensional `numpy.intp` array of length `X.shape[axis]` containing one interval value for each slice position. Slice equality MUST match the equality classes produced by `foapy.core.order(X, axis=axis)`. + +#### Scenario: Rows are sequence elements +- **WHEN** rows along axis 0 are `R0`, `R1`, `R0`, `R2`, `R0` and start binding with boundary mode is requested +- **THEN** the interval chain is `[1, 2, 2, 4, 2]` + +#### Scenario: Columns are sequence elements +- **WHEN** columns along axis 1 are `C0`, `C1`, `C0`, `C2` and start binding with boundary mode is requested +- **THEN** the interval chain is `[1, 2, 2, 4]` + +#### Scenario: Three-dimensional slices are sequence elements +- **WHEN** `intervals_chain()` receives a three-dimensional input and any explicit valid axis +- **THEN** it compares each complete two-dimensional slice at a position on that axis as one element and returns one interval value per selected-axis position + +#### Scenario: Order-code invariance +- **WHEN** a supported multidimensional dense input is evaluated with axis `a`, binding `b`, and chain mode `m` +- **THEN** its result equals `intervals_chain(order(X, axis=a), b, m)` + +### Requirement: Binding and chain mode operate in selected-axis coordinates +For explicit-axis input, the system MUST preserve the existing `binding.start`, `binding.end`, `chain_mode.boundary`, and `chain_mode.cycle` definitions using positions and sequence length from the selected axis. Boundary distances MUST be measured from the corresponding selected-axis edge, and cyclic distances for each equality class MUST wrap across the full selected-axis length. + +#### Scenario: End binding reverses the selected-axis frame +- **WHEN** slice elements along the selected axis are `S0`, `S1`, `S0`, `S2`, `S0` and end binding with boundary mode is requested +- **THEN** the interval chain in original axis order is `[2, 4, 2, 2, 1]` + +#### Scenario: Cycle mode wraps along the selected axis +- **WHEN** slice elements along the selected axis are `S0`, `S1`, `S0`, `S2`, `S0` and start binding with cycle mode is requested +- **THEN** the interval chain is `[1, 5, 2, 5, 2]` + +#### Scenario: Structurally empty slices remain equal +- **WHEN** a nonempty selected axis indexes slices whose orthogonal shape contains zero scalar fields +- **THEN** all such slices belong to one equality class and receive intervals according to their positions on the selected axis + +### Requirement: Axis behavior preserves the legacy core API +Calls that omit `axis` MUST retain the existing one-dimensional behavior, output dtype, and validation of `foapy.core.intervals_chain`. One-dimensional calls with `axis=0` or `axis=-1` MUST produce the legacy result. Multidimensional input without an explicit axis MUST raise `Not1DArrayException`; negative axes MUST follow NumPy conventions; an out-of-range axis MUST raise NumPy's axis error; and scalar inputs MUST raise `Not1DArrayException`. + +#### Scenario: Existing call without axis remains unchanged +- **WHEN** an existing caller passes a one-dimensional input without `axis` +- **THEN** the result is unchanged from the pre-axis API for every valid binding and chain mode + +#### Scenario: Explicit sole axis matches legacy behavior +- **WHEN** a one-dimensional input is called with `axis=0` or `axis=-1` +- **THEN** both results equal the call that omits `axis` + +#### Scenario: Multidimensional input requires explicit intent +- **WHEN** a multidimensional input is passed without `axis` +- **THEN** the function raises `Not1DArrayException` + +#### Scenario: Negative axis is equivalent +- **WHEN** a negative and equivalent positive axis select the same dimension +- **THEN** both calls return identical interval chains + +#### Scenario: Axis is out of range +- **WHEN** an explicit axis is outside the input dimensionality +- **THEN** the function raises NumPy's axis error + +#### Scenario: Scalar input is rejected +- **WHEN** `intervals_chain()` receives a zero-dimensional input +- **THEN** it raises `Not1DArrayException` + +#### Scenario: Empty sequence axis +- **WHEN** the selected input axis has length zero +- **THEN** the function returns an empty one-dimensional `numpy.intp` array + +### Requirement: Axis-aware interval chains are documented and benchmarked +The system MUST document the keyword-only axis, slice-as-element behavior, one-dimensional result shape, reconstruction-pipeline compatibility, and validation errors for core interval chains. The ASV suite MUST include time and peak-memory benchmarks for deterministic multidimensional records across representative sequence lengths, slice widths, and axis placements while retaining legacy one-dimensional coverage. + +#### Scenario: Public reference explains axis behavior +- **WHEN** a user opens the generated reference for `foapy.core.intervals_chain` +- **THEN** it includes runnable row or column examples and explains that the returned chain is one-dimensional along the selected axis + +#### Scenario: ASV discovers axis benchmarks +- **WHEN** the benchmark suite is collected +- **THEN** it includes deterministic time and peak-memory cases for core interval chains with multidimensional slice elements diff --git a/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/specs/partials-package/spec.md b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/specs/partials-package/spec.md new file mode 100644 index 00000000..d41dacf9 --- /dev/null +++ b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/specs/partials-package/spec.md @@ -0,0 +1,101 @@ +## MODIFIED Requirements + +### Requirement: Position-preserving partial interval chains +The system MUST provide `foapy.partials.intervals_chain(X, binding, chain_mode, *, axis=None)`, accepting a masked array or a plain sequence treated as fully unmasked. With no axis, it MUST retain its existing one-dimensional behavior. With an explicit axis, each complete orthogonal slice MUST be one sequence element, a fully masked slice MUST be a gap, and every slice MUST be either wholly masked or wholly unmasked. The result MUST be a one-dimensional masked `numpy.intp` array of length `X.shape[axis]`; non-gap positions MUST contain interval distances calculated from actual selected-axis indices and gap positions MUST remain masked. Gaps MUST count toward occurrence, boundary, and cyclic distances because they remain positions in the selected-axis coordinate system. + +#### Scenario: One-dimensional gaps count toward distance +- **WHEN** `intervals_chain()` receives `[--, C, T, C, --, G]` with start binding and boundary mode +- **THEN** it returns `[--, 2, 3, 2, --, 6]` + +#### Scenario: Whole masked slices are positional gaps +- **WHEN** multidimensional slices along the selected axis are `S0`, a wholly masked slice, `S1`, and `S0` with start binding and boundary mode +- **THEN** the function returns `[1, --, 3, 3]` + +#### Scenario: Cycle mode includes whole-slice gaps +- **WHEN** multidimensional slices along the selected axis are `S0`, a wholly masked slice, `S1`, and `S0` with start binding and cycle mode +- **THEN** the function returns `[1, --, 4, 3]`, using the full selected-axis length of four + +#### Scenario: Dense input matches core +- **WHEN** `intervals_chain()` receives a plain or fully unmasked input with any valid explicit axis +- **THEN** its non-masked values equal `foapy.core.intervals_chain()` for the same input, binding, chain mode, and axis + +#### Scenario: Fully masked input +- **WHEN** every slice along a nonempty selected axis is wholly masked +- **THEN** the function returns a fully masked one-dimensional array with the selected-axis length + +#### Scenario: Empty sequence axis +- **WHEN** the selected input axis has length zero +- **THEN** the function returns an empty one-dimensional masked `numpy.intp` array + +#### Scenario: Mixed mask inside one slice is rejected +- **WHEN** any slice along the selected axis contains both masked and unmasked scalar components +- **THEN** `intervals_chain()` raises `ValueError` because the slice does not define one present or absent sequence element + +#### Scenario: Multidimensional input without axis is rejected +- **WHEN** `intervals_chain()` receives a multidimensional input without an explicit axis +- **THEN** it raises `Not1DArrayException` + +#### Scenario: Negative axis is equivalent +- **WHEN** a negative and equivalent positive axis select the same dimension +- **THEN** both calls return interval chains with identical data and masks + +#### Scenario: Invalid axis or scalar input is rejected consistently +- **WHEN** the explicit axis is out of range or the input is zero-dimensional +- **THEN** the function raises NumPy's axis error for the invalid axis or `Not1DArrayException` for the scalar + +#### Scenario: Invalid modes are rejected +- **WHEN** `intervals_chain()` receives an unsupported binding or chain mode +- **THEN** it raises `ValueError` + +### Requirement: Published partial intervals-chain reference +The documentation MUST publish an API reference for `foapy.partials.intervals_chain` under the `foapy.partials` reference navigation. The reference MUST describe its keyword-only axis, slice-as-element behavior, one-dimensional masked return shape, whole-slice mask rules, source-position gap semantics, errors, and runnable one-dimensional and multidimensional examples. + +#### Scenario: Partials API page is discoverable +- **WHEN** a user browses the generated documentation's `foapy.partials` reference section +- **THEN** an `intervals_chain` entry links to the API reference page + +#### Scenario: Reference explains gap and axis semantics +- **WHEN** a user reads the partial intervals-chain reference +- **THEN** it explains that wholly masked slices remain masked positions, count toward distances along the selected axis, and differ from compressed semantics + +#### Scenario: Reference includes a multidimensional example +- **WHEN** a user reads the partial intervals-chain reference +- **THEN** it includes a runnable example with whole-slice gaps and an explicit axis + +### Requirement: Partials intervals-chain benchmark coverage +The benchmark suite MUST measure `foapy.partials.intervals_chain` execution time and peak memory for representative one-dimensional lengths and for deterministic multidimensional records across multiple sequence-axis lengths, slice widths, axis placements, and whole-slice mask states. The matrix MUST retain both bindings and both chain modes, and at least one axis-aware benchmark input MUST contain whole-slice gaps. + +#### Scenario: Benchmark suite covers the legacy parameter matrix +- **WHEN** the partials intervals-chain benchmark suite is collected +- **THEN** it retains length, dataset, binding, and chain-mode parameters covering the required one-dimensional sizes and both enum values + +#### Scenario: Benchmark suite covers axis inputs +- **WHEN** the partials intervals-chain benchmark suite is collected +- **THEN** it includes time and peak-memory cases parameterized by sequence-axis length, record width, axis placement, whole-slice mask state, binding, and chain mode + +#### Scenario: Benchmark exercises whole-slice gap semantics +- **WHEN** an axis-aware partial benchmark invokes `intervals_chain` +- **THEN** at least one case passes a masked multidimensional input with wholly masked slices rather than compressing or preprocessing it outside the timed call + +### Requirement: Package boundary and dense parity +The system MUST expose exactly `order`, `alphabet`, `intervals_chain`, and `intervals_tuple` from `foapy.partials`. For one-dimensional inputs and for wholly present slice elements along an explicit axis, partial alphabet, order, and interval-chain results MUST match their corresponding core results, subject to the documented masked-array representation. Existing partial interval behavior and top-level package exports MUST remain unchanged. + +#### Scenario: Submodule-only access +- **WHEN** a caller imports `foapy.partials` +- **THEN** the four partials functions are available from that submodule and are not added as top-level `foapy` functions + +#### Scenario: Zero-gap parity for alphabet and order +- **WHEN** a caller uses a plain or fully unmasked input with any valid explicit axis +- **THEN** the partial alphabet equals the core alphabet and the non-masked values of the partial order equal the core order + +#### Scenario: Zero-gap parity for interval chains +- **WHEN** a caller uses a plain or fully unmasked input with any valid explicit axis, binding, and chain mode +- **THEN** the partial interval-chain mask is empty and its values equal the core interval chain + +#### Scenario: Existing one-dimensional pipeline parity +- **WHEN** a caller uses an unmasked one-dimensional input and valid combinations of binding, chain mode, and tuple mode +- **THEN** partials results match the corresponding core results, subject to the documented masked-array representation + +#### Scenario: Existing interval gap behavior remains unchanged +- **WHEN** a caller passes a one-dimensional partial sequence through ordering, interval-chain, and tuple operations +- **THEN** every source gap retains the behavior documented by the partial interval requirements diff --git a/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/tasks.md b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/tasks.md new file mode 100644 index 00000000..9ecc78a3 --- /dev/null +++ b/openspec/changes/archive/2026-09-09-add-axis-support-to-intervals-chain/tasks.md @@ -0,0 +1,51 @@ +## 1. Core Interval-Chain Implementation + +- [x] 1.1 Extract or define a private one-dimensional core interval-chain kernel that preserves the current grouping, binding, boundary, cycle, empty-input, dtype, and return semantics. +- [x] 1.2 Add typed keyword-only `axis=None` support to `foapy.core.intervals_chain` and reuse the shared axis-normalization rules from alphabet/order. +- [x] 1.3 Keep one-dimensional inputs, including explicit `axis=0` and `axis=-1`, on the legacy direct kernel without multidimensional factorization. +- [x] 1.4 Factorize multidimensional slice elements through the established core order path and apply the one-dimensional interval kernel to its equality codes. +- [x] 1.5 Preserve binding and chain-mode validation behavior and verify scalar, missing-axis, and invalid-axis errors use the specified exception types. + +## 2. Partial Interval-Chain Implementation + +- [x] 2.1 Extract or define a private one-dimensional partial interval-chain kernel that preserves full-domain gap positions and existing binding and chain-mode behavior. +- [x] 2.2 Add typed keyword-only `axis=None` support to `foapy.partials.intervals_chain` using the shared axis-normalization rules. +- [x] 2.3 Keep one-dimensional partial inputs on the legacy direct path, including explicit sole-axis calls. +- [x] 2.4 Convert multidimensional partial slices through `foapy.partials.order` so whole-slice gaps, mixed-mask validation, and equality classes match partial alphabet/order before applying the interval kernel. +- [x] 2.5 Verify gap slices remain masked and count toward occurrence, boundary, and cyclic distances across the complete selected-axis domain. + +## 3. Core Tests + +- [x] 3.1 Add row- and column-element tests for core interval chains with both bindings and both chain modes, including the specified concrete interval values. +- [x] 3.2 Add three-dimensional and order-code-invariance tests across every valid axis. +- [x] 3.3 Add positive/negative axis equivalence, explicit one-dimensional axis, multidimensional-without-axis, invalid-axis, scalar, and empty selected-axis tests. +- [x] 3.4 Add structurally empty-slice coverage and verify the result remains one-dimensional with `numpy.intp` dtype. +- [x] 3.5 Add a dispatch regression test proving one-dimensional calls do not enter the multidimensional factorization path. + +## 4. Partial Tests + +- [x] 4.1 Add multidimensional whole-slice gap tests for start/end binding and boundary/cycle modes, checking both interval data and masks. +- [x] 4.2 Add plain and fully unmasked multidimensional parity tests against the core API across valid axes and mode combinations. +- [x] 4.3 Add mixed-mask rejection, fully masked, empty selected-axis, structurally empty-slice, negative-axis, invalid-axis, scalar, and missing-axis tests. +- [x] 4.4 Add a dispatch regression test proving one-dimensional partial calls retain the direct path and existing gap behavior. +- [x] 4.5 Run the existing one-dimensional core, partial, pipeline, tuple, and congeneric suites to confirm compatibility. + +## 5. Documentation + +- [x] 5.1 Update the core `intervals_chain` signature, annotations, return-shape description, errors, and docstring with runnable row/column axis examples. +- [x] 5.2 Update the partial `intervals_chain` signature, annotations, whole-slice mask rules, selected-axis gap-distance semantics, errors, and docstring with a runnable multidimensional example. +- [x] 5.3 Update relevant fundamental documentation to explain that interval chains remain one-dimensional and compose directly with `intervals_tuple` after slice-as-element processing. +- [x] 5.4 Build the MkDocs site and resolve reference or example failures. + +## 6. Benchmarks + +- [x] 6.1 Extend the core interval-chain ASV module with deterministic axis benchmarks for representative sequence lengths, record widths, axis placements, bindings, and chain modes. +- [x] 6.2 Extend the partial interval-chain ASV module with deterministic unmasked, whole-slice-gapped, and fully masked axis cases while retaining the existing one-dimensional matrix. +- [x] 6.3 Add both time and peak-memory methods and practical skip/timeout bounds for the new benchmark matrices. +- [x] 6.4 Verify ASV discovery and smoke-run the new cases without changing stored machine-specific benchmark results. + +## 7. Final Verification + +- [x] 7.1 Run the complete pytest suite and confirm all legacy and axis-aware tests pass. +- [x] 7.2 Run Black, isort, flake8, and `git diff --check` on the completed change. +- [x] 7.3 Run strict OpenSpec validation and confirm every proposal requirement is represented by implementation and verification tasks. diff --git a/openspec/changes/refactor-congeneric-characteristics/design.md b/openspec/changes/refactor-congeneric-characteristics/design.md deleted file mode 100644 index 6c7569c7..00000000 --- a/openspec/changes/refactor-congeneric-characteristics/design.md +++ /dev/null @@ -1,67 +0,0 @@ -## Context - -FoaPy's congeneric decomposition pipeline (`foapy.congenerics`) was introduced to supersede `foapy.ma`, which implemented the same concept (decomposing a sequence into per-symbol masked arrays) using an older masked-array API. Similarly, `foapy.characteristics` accumulated functions that require grouped/congeneric input (`descriptive_information`, `identifying_information`, `regularity`, `uniformity`) — these were misplaced alongside true flat-interval characteristics. - -The result is two sources of confusion: -1. `foapy.ma` and `foapy.congenerics` both model congeneric sequences, creating redundancy -2. `foapy.characteristics` mixes flat-interval functions (input: 1-D array → scalar) with grouped-interval functions (input: list of arrays → scalar) - -## Goals / Non-Goals - -**Goals:** -- Create `foapy.congenerics.characteristics` as the single home for all congeneric characteristics -- Establish a clear naming convention: singular = scalar aggregate; plural = per-symbol array -- Remove `foapy.ma` and `foapy.characteristics.ma` entirely -- Update tests and docs to reflect the new structure; no deprecation cycle - -**Non-Goals:** -- Changing any characteristic's mathematical definition or algorithm -- Adding new characteristics -- Modifying `foapy.core`, `foapy.partials`, or `foapy.characteristics` flat functions (`arithmetic_mean`, `average_remoteness`, `depth`, `geometric_mean`, `volume`) - -## Decisions - -### 1. `congenerics.characteristics` as a subpackage, not flat exports - -**Decision:** `foapy.congenerics.characteristics` is a subpackage (a directory with `__init__.py`), accessed via `import foapy.congenerics.characteristics` or `from foapy.congenerics import characteristics`. Functions are NOT promoted to the `foapy.congenerics` top-level namespace. - -**Rationale:** Mirrors the existing `foapy.characteristics` pattern. Keeps `foapy.congenerics`'s top-level namespace focused on the decomposition pipeline (sequences, order, intervals_chains, etc.). - -**Alternative considered:** Export everything flat from `foapy.congenerics`. Rejected — it would blur the line between pipeline primitives and analysis functions. - -### 2. Singular/plural naming convention - -**Decision:** Singular names (`identifying_information`, `uniformity`) for scalar aggregates over all congeneric groups; plural names (`identifying_informations`, `uniformities`) for per-symbol arrays. - -**Rationale:** The name alone signals the return type and expected input. A caller importing `volumes` knows they get one value per symbol; importing `volume` from `foapy.characteristics` knows they get a scalar. - -**Alternative considered:** Suffix-based names (`identifying_information_array` vs `identifying_information_scalar`). Rejected — verbose and inconsistent with numpy's convention of pluralizing array-returning functions (e.g., `indices`, `values`). - -### 3. Clean deletion of `foapy.ma` and `foapy.characteristics.ma` - -**Decision:** Delete both packages outright. No deprecation shims, no re-exports. - -**Rationale:** These are internal library packages. Backward compatibility is explicitly not required. Deprecation wrappers would leave dead code and confuse future contributors. - -**Alternative considered:** Keep `foapy.ma` as a thin alias. Rejected — it would perpetuate confusion about which package models congeneric sequences. - -### 4. Internal cross-references in moved functions - -**Decision:** Functions moved from `characteristics` to `congenerics.characteristics` update their internal imports. For example, `descriptive_information` currently calls `from foapy.characteristics import identifying_information`; after the move it calls `from foapy.congenerics.characteristics import identifying_information`. - -**Rationale:** Avoids circular imports and keeps dependencies within the package boundary. - -### 5. Test file placement - -**Decision:** -- Top-level `tests/test_ma_*.py` files are deleted (the congenerics package already has full test coverage) -- `tests/test_characteristics/test_ma_*.py` are moved to `tests/test_congenerics_characteristics/` and renamed to match plural function names (e.g., `test_ma_volume.py` → `test_volumes.py`) -- `tests/test_characteristics/test_descriptive_information.py`, `test_identifying_information.py`, `test_regularity.py`, `test_uniformity.py` move to `tests/test_congenerics_characteristics/` - -**Rationale:** Mirrors the source structure. Keeps congenerics tests together. - -## Risks / Trade-offs - -- [Scope] Many files touch this change (source, tests, docs). → Mitigation: implement in a single branch with a full test run before merge. -- [Internal imports] Moved functions may import each other (e.g., `regularity` uses `descriptive_information` and `geometric_mean`). → Mitigation: update all cross-references during the move; verify with `tox -e default` after each group. -- [Docs examples] Docstrings in moved files use `foapy.ma.order` / `foapy.ma.intervals` in their examples. → Mitigation: update examples to use `foapy.congenerics` pipeline as part of the move. diff --git a/openspec/changes/refactor-congeneric-characteristics/proposal.md b/openspec/changes/refactor-congeneric-characteristics/proposal.md deleted file mode 100644 index 5c53ed12..00000000 --- a/openspec/changes/refactor-congeneric-characteristics/proposal.md +++ /dev/null @@ -1,48 +0,0 @@ -## Why - -`foapy.ma` duplicates the congeneric concept now owned by `foapy.congenerics`, and `foapy.characteristics` contains functions that already require grouped/congeneric input, making them misplaced. Consolidating these into `foapy.congenerics.characteristics` aligns the package structure with the actual computational model. - -## What Changes - -- **New** `foapy.congenerics.characteristics` subpackage added to `foapy.congenerics` -- **Moved** from `foapy.characteristics` → `foapy.congenerics.characteristics` (name unchanged, scalar aggregates over congeneric groups): - - `descriptive_information` - - `identifying_information` - - `regularity` - - `uniformity` -- **Moved + renamed** from `foapy.characteristics.ma` → `foapy.congenerics.characteristics` (plural names, per-symbol arrays): - - `arithmetic_mean` → `arithmetic_means` - - `average_remoteness` → `average_remotenesses` - - `depth` → `depths` - - `geometric_mean` → `geometric_means` - - `identifying_information` → `identifying_informations` - - `periodicity` → `periodicities` - - `uniformity` → `uniformities` - - `volume` → `volumes` -- **BREAKING** `foapy.ma` removed (superseded by `foapy.congenerics`) -- **BREAKING** `foapy.characteristics.ma` removed (superseded by `foapy.congenerics.characteristics`) -- **BREAKING** `foapy.characteristics.descriptive_information`, `.identifying_information`, `.regularity`, `.uniformity` removed from `foapy.characteristics` -- Tests and docs updated throughout; no deprecation cycle - -## Capabilities - -### New Capabilities - -- `congeneric-characteristics`: Per-symbol and aggregate characteristics for congeneric interval decompositions, exposed as `foapy.congenerics.characteristics` - -### Modified Capabilities - -- `congeneric-decomposition`: Public API extended — `foapy.congenerics` now exposes a `characteristics` subpackage - -## Impact - -- `src/foapy/ma/` — deleted entirely -- `src/foapy/characteristics/ma/` — deleted entirely -- `src/foapy/characteristics/__init__.py` — removes `descriptive_information`, `identifying_information`, `regularity`, `uniformity` and the `ma` subpackage -- `src/foapy/congenerics/` — gains `characteristics/` subpackage -- `src/foapy/__init__.py` — removes `ma` from `__foapy_submodules__` -- `tests/test_ma_*.py` — deleted (congenerics already tested) -- `benchmarks/benchmarks/bench_ma_*.py`, `benchmarks/benchmarks/ma_cases.py` — deleted -- `tests/test_characteristics/test_ma_*.py` — replaced with `test_congenerics_characteristics/test_*s.py` (plural names) -- `tests/test_characteristics/test_descriptive_information.py`, `test_identifying_information.py`, `test_regularity.py`, `test_uniformity.py` — moved to `test_congenerics_characteristics/` -- Docs: all `foapy.ma` and `foapy.characteristics.ma` references updated diff --git a/openspec/changes/refactor-congeneric-characteristics/specs/congeneric-characteristics/spec.md b/openspec/changes/refactor-congeneric-characteristics/specs/congeneric-characteristics/spec.md deleted file mode 100644 index 0c71550b..00000000 --- a/openspec/changes/refactor-congeneric-characteristics/specs/congeneric-characteristics/spec.md +++ /dev/null @@ -1,340 +0,0 @@ -## ADDED Requirements - -### Requirement: congeneric-characteristics subpackage -The system MUST expose `foapy.congenerics.characteristics` as a subpackage accessible via `import foapy.congenerics.characteristics` or `from foapy.congenerics import characteristics`. It MUST NOT promote any characteristic function to the `foapy.congenerics` top-level namespace. - -#### Scenario: Subpackage import -- **WHEN** a caller runs `import foapy.congenerics.characteristics` -- **THEN** the import succeeds and the module exposes all congeneric characteristic functions - -#### Scenario: No top-level promotion -- **WHEN** a caller inspects `dir(foapy.congenerics)` -- **THEN** individual characteristic function names are not present at the top level - -### Requirement: Scalar aggregate characteristics (singular names) -The system MUST provide the following functions in `foapy.congenerics.characteristics`, each accepting `intervals_grouped` (a sequence of 1-D arrays, one per congeneric group) and returning a single scalar. Their mathematical definitions MUST be identical to those previously in `foapy.characteristics`: -- `descriptive_information(intervals_grouped, dtype=None)` -- `identifying_information(intervals_grouped, dtype=None)` -- `regularity(intervals_grouped, dtype=None)` -- `uniformity(intervals_grouped, dtype=None)` - -#### Scenario: identifying_information computes weighted-log-mean scalar - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals_grouped = [row[row != 0] for row in tuples] - -print(intervals_grouped) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -# m = 4 -# n_0 = 3 -# n_1 = 1 -# n_2 = 1 -# n_3 = 1 -# n = 6 - -result = foapy.congenerics.characteristics.identifying_information(intervals_grouped) -print(result) -# 1.299309880536629 - -result = foapy.congenerics.characteristics.identifying_information(intervals_grouped, dtype=np.longdouble) -print(result) -# 1.2993098805366290618 -``` - -#### Scenario: descriptive_information computes 2^H scalar - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals_grouped = [row[row != 0] for row in tuples] - -print(intervals_grouped) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -# m = 4 -# n_0 = 3 -# n_1 = 1 -# n_2 = 1 -# n_3 = 1 -# n = 6 - -result = foapy.congenerics.characteristics.descriptive_information(intervals_grouped) -print(result) -# 2.4611112617624173 - -result = foapy.congenerics.characteristics.descriptive_information(intervals_grouped, dtype=np.longdouble) -print(result) -# 2.4611112617624174427 -``` - -#### Scenario: regularity computes geometric-mean / descriptive-information ratio - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals_grouped = [row[row != 0] for row in tuples] - -print(intervals_grouped) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -# m = 4 -# n_0 = 3 -# n_1 = 1 -# n_2 = 1 -# n_3 = 1 -# n = 6 - -result = foapy.congenerics.characteristics.regularity(intervals_grouped) -print(result) -# 0.9759306487558016 - -result = foapy.congenerics.characteristics.regularity(intervals_grouped, dtype=np.longdouble) -print(result) -# 0.97593064875580153104 -``` - -#### Scenario: uniformity computes identifying_information minus average_remoteness - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals_grouped = [row[row != 0] for row in tuples] - -print(intervals_grouped) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -# m = 4 -# n_0 = 3 -# n_1 = 1 -# n_2 = 1 -# n_3 = 1 -# n = 6 - -result = foapy.congenerics.characteristics.uniformity(intervals_grouped) -print(result) -# 0.03514946374976957 - -result = foapy.congenerics.characteristics.uniformity(intervals_grouped, dtype=np.longdouble) -print(result) -# 0.03514946374976969819 -``` - -### Requirement: Per-symbol array characteristics (plural names) -The system MUST provide the following functions in `foapy.congenerics.characteristics`, each accepting a sequence of congeneric intervals arrays and returning a 1-D numpy array with one value per congeneric group. Their mathematical definitions MUST be identical to those previously in `foapy.characteristics.ma`: -- `arithmetic_means(intervals, dtype=None)` -- `average_remotenesses(intervals, dtype=None)` -- `depths(intervals, dtype=None)` -- `geometric_means(intervals, dtype=None)` -- `identifying_informations(intervals, dtype=None)` -- `periodicities(intervals, dtype=None)` -- `uniformities(intervals, dtype=None)` -- `volumes(intervals, dtype=None)` - -#### Scenario: volumes returns per-symbol product array - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals = [row[row != 0] for row in tuples] - -print(intervals) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -result = foapy.congenerics.characteristics.volumes(intervals) -print(result) -# [4 2 4 6] -``` - -#### Scenario: arithmetic_means returns per-symbol arithmetic mean array - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals = [row[row != 0] for row in tuples] - -print(intervals) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -result = foapy.congenerics.characteristics.arithmetic_means(intervals) -print(result) -# [1.66666667 2. 4. 6. ] -``` - -#### Scenario: identifying_informations returns per-symbol log2-of-mean array - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals = [row[row != 0] for row in tuples] - -print(intervals) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -result = foapy.congenerics.characteristics.identifying_informations(intervals) -print(result) -# [0.73696559 1. 2. 2.5849625 ] -``` - -#### Scenario: periodicities returns per-symbol geometric/arithmetic mean ratio array - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals = [row[row != 0] for row in tuples] - -print(intervals) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -result = foapy.congenerics.characteristics.periodicities(intervals) -print(result) -# [0.95244121 1. 1. 1. ] -``` - -#### Scenario: depths returns per-symbol sum-of-log2 array - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals = [row[row != 0] for row in tuples] - -print(intervals) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -result = foapy.congenerics.characteristics.depths(intervals) -print(result) -# [2. 1. 2. 2.5849625] -``` - -#### Scenario: average_remotenesses returns per-symbol mean-of-log2 array - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals = [row[row != 0] for row in tuples] - -print(intervals) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -result = foapy.congenerics.characteristics.average_remotenesses(intervals) -print(result) -# [0.66666667 1. 2. 2.5849625 ] -``` - -#### Scenario: geometric_means returns per-symbol geometric mean array - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals = [row[row != 0] for row in tuples] - -print(intervals) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -result = foapy.congenerics.characteristics.geometric_means(intervals) -print(result) -# [1.58740105 2. 4. 6. ] -``` - -#### Scenario: uniformities returns per-symbol identifying_information minus average_remoteness array - -``` py linenums="1" -import foapy -import numpy as np - -source = np.array(['a', 'b', 'a', 'c', 'a', 'd']) -CS = foapy.congenerics.sequences(source) -chains = foapy.congenerics.intervals_chains(CS, foapy.binding.start, foapy.chain_mode.boundary) -tuples = foapy.congenerics.intervals_tuples(chains, foapy.binding.start, foapy.tuple_mode.normal) -intervals = [row[row != 0] for row in tuples] - -print(intervals) -# [array([1, 2, 2]), array([2]), array([4]), array([6])] - -result = foapy.congenerics.characteristics.uniformities(intervals) -print(result) -# [0.07030559 0. 0. 0. ] -``` - -#### Scenario: Per-symbol array has one entry per congeneric group -- **WHEN** any plural characteristic function is called with `m` congeneric interval arrays -- **THEN** it returns a 1-D array of length `m` - -### Requirement: Removed packages -The system MUST NOT expose `foapy.ma` or `foapy.characteristics.ma` after this change. Importing either MUST raise `ModuleNotFoundError` (or `ImportError`). - -#### Scenario: foapy.ma is no longer importable -- **WHEN** a caller runs `import foapy.ma` -- **THEN** it raises `ModuleNotFoundError` - -#### Scenario: foapy.characteristics.ma is no longer importable -- **WHEN** a caller runs `from foapy.characteristics import ma` -- **THEN** it raises `ImportError` or `ModuleNotFoundError` - -### Requirement: foapy.characteristics retains only flat-interval functions -The system MUST ensure `foapy.characteristics` exposes exactly `arithmetic_mean`, `average_remoteness`, `depth`, `geometric_mean`, `volume` after this change. The functions `descriptive_information`, `identifying_information`, `regularity`, `uniformity` MUST NOT be importable from `foapy.characteristics`. - -#### Scenario: Moved functions are not importable from foapy.characteristics -- **WHEN** a caller runs `from foapy.characteristics import descriptive_information` -- **THEN** it raises `ImportError` - -#### Scenario: Flat-interval functions remain in foapy.characteristics -- **WHEN** a caller runs `from foapy.characteristics import volume` -- **THEN** the import succeeds and returns the flat-interval scalar function diff --git a/openspec/changes/refactor-congeneric-characteristics/specs/congeneric-decomposition/spec.md b/openspec/changes/refactor-congeneric-characteristics/specs/congeneric-decomposition/spec.md deleted file mode 100644 index 0223c45a..00000000 --- a/openspec/changes/refactor-congeneric-characteristics/specs/congeneric-decomposition/spec.md +++ /dev/null @@ -1,16 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Package boundary -The system MUST expose exactly `sequences`, `alphabet`, `order`, `intervals_chains`, `intervals_tuples`, `intervals_distributions`, and `characteristics` from `foapy.congenerics`. The `characteristics` entry MUST be the `foapy.congenerics.characteristics` subpackage. `foapy.congenerics` MUST NOT expose an inverse/reconstruction function. `foapy.ma` is removed and MUST NOT be referenced. - -#### Scenario: Submodule-only access -- **WHEN** a caller imports `foapy.congenerics` -- **THEN** exactly the six pipeline functions plus the `characteristics` subpackage are available, and no individual characteristic functions are added to the `foapy.congenerics` top-level namespace - -#### Scenario: No inverse function is present -- **WHEN** a caller inspects `foapy.congenerics`'s public API -- **THEN** no reconstruction/inverse function exists in this module - -#### Scenario: characteristics subpackage is accessible -- **WHEN** a caller runs `import foapy.congenerics.characteristics` -- **THEN** the import succeeds and `foapy.congenerics.characteristics` is the congeneric characteristics subpackage diff --git a/openspec/changes/refactor-congeneric-characteristics/tasks.md b/openspec/changes/refactor-congeneric-characteristics/tasks.md deleted file mode 100644 index 406fad75..00000000 --- a/openspec/changes/refactor-congeneric-characteristics/tasks.md +++ /dev/null @@ -1,91 +0,0 @@ -## 1. Create foapy.congenerics.characteristics subpackage - -- [x] 1.1 Create `src/foapy/congenerics/characteristics/` directory with `__init__.py` exporting all congeneric characteristic functions -- [x] 1.2 Move `src/foapy/characteristics/_descriptive_information.py` → `src/foapy/congenerics/characteristics/_descriptive_information.py`; update internal imports (`from foapy.characteristics import identifying_information` → `from foapy.congenerics.characteristics import identifying_information`) -- [x] 1.3 Move `src/foapy/characteristics/_identifying_information.py` → `src/foapy/congenerics/characteristics/_identifying_information.py`; verify no internal imports need updating -- [x] 1.4 Move `src/foapy/characteristics/_regularity.py` → `src/foapy/congenerics/characteristics/_regularity.py`; update internal imports (`from foapy.characteristics import descriptive_information, geometric_mean` → appropriate new paths) -- [x] 1.5 Move `src/foapy/characteristics/_uniformity.py` (the grouped version) → `src/foapy/congenerics/characteristics/_uniformity.py`; update internal imports - -## 2. Move and rename characteristics.ma functions to congenerics.characteristics - -- [x] 2.1 Copy `src/foapy/characteristics/ma/_arithmetic_mean.py` → `src/foapy/congenerics/characteristics/_arithmetic_means.py`; rename function to `arithmetic_means`; update docstring -- [x] 2.2 Copy `src/foapy/characteristics/ma/_average_remoteness.py` → `src/foapy/congenerics/characteristics/_average_remotenesses.py`; rename function to `average_remotenesses`; update docstring and internal imports -- [x] 2.3 Copy `src/foapy/characteristics/ma/_depth.py` → `src/foapy/congenerics/characteristics/_depths.py`; rename function to `depths`; update docstring and internal imports -- [x] 2.4 Copy `src/foapy/characteristics/ma/_geometric_mean.py` → `src/foapy/congenerics/characteristics/_geometric_means.py`; rename function to `geometric_means`; update docstring and internal imports -- [x] 2.5 Copy `src/foapy/characteristics/ma/_identifying_information.py` → `src/foapy/congenerics/characteristics/_identifying_informations.py`; rename function to `identifying_informations`; update docstring -- [x] 2.6 Copy `src/foapy/characteristics/ma/_periodicity.py` → `src/foapy/congenerics/characteristics/_periodicities.py`; rename function to `periodicities`; update docstring and internal imports -- [x] 2.7 Copy `src/foapy/characteristics/ma/_uniformity.py` → `src/foapy/congenerics/characteristics/_uniformities.py`; rename function to `uniformities`; update docstring and internal imports -- [x] 2.8 Copy `src/foapy/characteristics/ma/_volume.py` → `src/foapy/congenerics/characteristics/_volumes.py`; rename function to `volumes`; update docstring - -## 3. Update docstring examples in moved files - -- [x] 3.1 In all moved scalar aggregate files, replace `foapy.ma.order` / `foapy.ma.intervals` examples with `foapy.congenerics` pipeline equivalents -- [x] 3.2 In all plural array files, replace `foapy.characteristics.ma.*` import examples with `foapy.congenerics.characteristics.*` - -## 4. Update foapy.characteristics package - -- [x] 4.1 Remove `descriptive_information`, `identifying_information`, `regularity`, `uniformity` from `src/foapy/characteristics/__init__.py` exports and `__all__` -- [x] 4.2 Delete `src/foapy/characteristics/_descriptive_information.py`, `_identifying_information.py`, `_regularity.py`, `_uniformity.py` (the moved originals) -- [x] 4.3 Delete `src/foapy/characteristics/ma/` directory entirely - -## 5. Update foapy.congenerics package - -- [x] 5.1 Add `characteristics` to `src/foapy/congenerics/__init__.py` as a lazy-loaded submodule (mirroring how `foapy.__init__.py` exposes submodules) - -## 6. Remove foapy.ma package - -- [x] 6.1 Delete `src/foapy/ma/` directory entirely -- [x] 6.2 Remove `ma` from `__foapy_submodules__` in `src/foapy/__init__.py` -- [x] 6.3 Remove the `ma` case from `foapy.__getattr__` in `src/foapy/__init__.py` -- [x] 6.4 Remove `ma` from `__all__` and `__dir__` in `src/foapy/__init__.py` - -## 7. Update tests — remove foapy.ma tests - -- [x] 7.1 Delete `tests/test_ma_order.py` -- [x] 7.2 Delete `tests/test_ma_alphabet.py` -- [x] 7.3 Delete `tests/test_ma_intervals.py` -- [x] 7.4 Delete `tests/test_ma_intervals_chain.py` -- [x] 7.5 Delete `tests/test_ma_intervals_tuple.py` -- [x] 7.6 Delete `tests/test_ma_intervals_distribution.py` -- [x] 7.7 Keep `tests/helpers/ma_intervals.py` — no `foapy.ma` imports; still used by congenerics characteristics tests - -## 8. Update tests — migrate characteristics.ma tests - -- [x] 8.1 Create `tests/test_congenerics_characteristics/` directory -- [x] 8.2 Move and update `tests/test_characteristics/test_ma_volume.py` → `tests/test_congenerics_characteristics/test_volumes.py`; update import to `from foapy.congenerics.characteristics import volumes`; rename test class/methods to reflect plural name -- [x] 8.3 Move and update `tests/test_characteristics/test_ma_arithmetic_mean.py` → `tests/test_congenerics_characteristics/test_arithmetic_means.py` -- [x] 8.4 Move and update `tests/test_characteristics/test_ma_average_remoteness.py` → `tests/test_congenerics_characteristics/test_average_remotenesses.py` -- [x] 8.5 Move and update `tests/test_characteristics/test_ma_depth.py` → `tests/test_congenerics_characteristics/test_depths.py` -- [x] 8.6 Move and update `tests/test_characteristics/test_ma_geometric_mean.py` → `tests/test_congenerics_characteristics/test_geometric_means.py` -- [x] 8.7 Move and update `tests/test_characteristics/test_ma_identifying_information.py` → `tests/test_congenerics_characteristics/test_identifying_informations.py` -- [x] 8.8 Move and update `tests/test_characteristics/test_ma_periodicity.py` → `tests/test_congenerics_characteristics/test_periodicities.py` -- [x] 8.9 Move and update `tests/test_characteristics/test_ma_uniformity.py` → `tests/test_congenerics_characteristics/test_uniformities.py` - -## 9. Update tests — migrate scalar aggregate characteristic tests - -- [x] 9.1 Move `tests/test_characteristics/test_descriptive_information.py` → `tests/test_congenerics_characteristics/test_descriptive_information.py`; update import to `from foapy.congenerics.characteristics import descriptive_information` -- [x] 9.2 Move `tests/test_characteristics/test_identifying_information.py` → `tests/test_congenerics_characteristics/test_identifying_information.py`; update import -- [x] 9.3 Move `tests/test_characteristics/test_regularity.py` → `tests/test_congenerics_characteristics/test_regularity.py`; update import -- [x] 9.4 Move `tests/test_characteristics/test_uniformity.py` → `tests/test_congenerics_characteristics/test_uniformity.py`; update import - -## 10. Update docs - -- [x] 10.1 Search all docs files for `foapy.ma` references and replace with `foapy.congenerics` equivalents -- [x] 10.2 Search all docs files for `foapy.characteristics.ma` references and replace with `foapy.congenerics.characteristics` equivalents (using plural function names) -- [x] 10.3 Update API reference pages for the `characteristics` module to remove moved functions -- [x] 10.4 Add API reference page for `foapy.congenerics.characteristics` - -## 11. Verify - -- [x] 11.1 Run `tox -e default` — all tests pass (465 passed) -- [x] 11.2 Run `pre-commit run --all-files` — lint passes (isort, black, flake8 all pass) -- [x] 11.3 Verify `import foapy.ma` raises `ModuleNotFoundError` ✓ -- [x] 11.4 Verify `from foapy.characteristics import descriptive_information` raises `ImportError` ✓ -- [x] 11.5 Verify `import foapy.congenerics.characteristics` succeeds and all 12 functions are accessible ✓ - -## 12. Remove foapy.ma benchmarks - -- [x] 12.1 Delete `benchmarks/benchmarks/bench_ma_alphabet.py` -- [x] 12.2 Delete `benchmarks/benchmarks/bench_ma_intervals.py` -- [x] 12.3 Delete `benchmarks/benchmarks/bench_ma_order.py` -- [x] 12.4 Delete `benchmarks/benchmarks/ma_cases.py` diff --git a/openspec/specs/axis-aware-interval-chains/spec.md b/openspec/specs/axis-aware-interval-chains/spec.md new file mode 100644 index 00000000..fd8e47c4 --- /dev/null +++ b/openspec/specs/axis-aware-interval-chains/spec.md @@ -0,0 +1,81 @@ +# Purpose + +Define interval-chain behavior when complete multidimensional slices are treated as sequence elements along an explicit axis. + +## Requirements + +### Requirement: Core interval chains support slice elements along an axis +The system MUST provide `foapy.core.intervals_chain(X, binding, chain_mode, *, axis=None)`. When `axis` is an integer, each complete orthogonal slice indexed along that axis MUST be one sequence element, and the function MUST return a one-dimensional `numpy.intp` array of length `X.shape[axis]` containing one interval value for each slice position. Slice equality MUST match the equality classes produced by `foapy.core.order(X, axis=axis)`. + +#### Scenario: Rows are sequence elements +- **WHEN** rows along axis 0 are `R0`, `R1`, `R0`, `R2`, `R0` and start binding with boundary mode is requested +- **THEN** the interval chain is `[1, 2, 2, 4, 2]` + +#### Scenario: Columns are sequence elements +- **WHEN** columns along axis 1 are `C0`, `C1`, `C0`, `C2` and start binding with boundary mode is requested +- **THEN** the interval chain is `[1, 2, 2, 4]` + +#### Scenario: Three-dimensional slices are sequence elements +- **WHEN** `intervals_chain()` receives a three-dimensional input and any explicit valid axis +- **THEN** it compares each complete two-dimensional slice at a position on that axis as one element and returns one interval value per selected-axis position + +#### Scenario: Order-code invariance +- **WHEN** a supported multidimensional dense input is evaluated with axis `a`, binding `b`, and chain mode `m` +- **THEN** its result equals `intervals_chain(order(X, axis=a), b, m)` + +### Requirement: Binding and chain mode operate in selected-axis coordinates +For explicit-axis input, the system MUST preserve the existing `binding.start`, `binding.end`, `chain_mode.boundary`, and `chain_mode.cycle` definitions using positions and sequence length from the selected axis. Boundary distances MUST be measured from the corresponding selected-axis edge, and cyclic distances for each equality class MUST wrap across the full selected-axis length. + +#### Scenario: End binding reverses the selected-axis frame +- **WHEN** slice elements along the selected axis are `S0`, `S1`, `S0`, `S2`, `S0` and end binding with boundary mode is requested +- **THEN** the interval chain in original axis order is `[2, 4, 2, 2, 1]` + +#### Scenario: Cycle mode wraps along the selected axis +- **WHEN** slice elements along the selected axis are `S0`, `S1`, `S0`, `S2`, `S0` and start binding with cycle mode is requested +- **THEN** the interval chain is `[1, 5, 2, 5, 2]` + +#### Scenario: Structurally empty slices remain equal +- **WHEN** a nonempty selected axis indexes slices whose orthogonal shape contains zero scalar fields +- **THEN** all such slices belong to one equality class and receive intervals according to their positions on the selected axis + +### Requirement: Axis behavior preserves the legacy core API +Calls that omit `axis` MUST retain the existing one-dimensional behavior, output dtype, and validation of `foapy.core.intervals_chain`. One-dimensional calls with `axis=0` or `axis=-1` MUST produce the legacy result. Multidimensional input without an explicit axis MUST raise `Not1DArrayException`; negative axes MUST follow NumPy conventions; an out-of-range axis MUST raise NumPy's axis error; and scalar inputs MUST raise `Not1DArrayException`. + +#### Scenario: Existing call without axis remains unchanged +- **WHEN** an existing caller passes a one-dimensional input without `axis` +- **THEN** the result is unchanged from the pre-axis API for every valid binding and chain mode + +#### Scenario: Explicit sole axis matches legacy behavior +- **WHEN** a one-dimensional input is called with `axis=0` or `axis=-1` +- **THEN** both results equal the call that omits `axis` + +#### Scenario: Multidimensional input requires explicit intent +- **WHEN** a multidimensional input is passed without `axis` +- **THEN** the function raises `Not1DArrayException` + +#### Scenario: Negative axis is equivalent +- **WHEN** a negative and equivalent positive axis select the same dimension +- **THEN** both calls return identical interval chains + +#### Scenario: Axis is out of range +- **WHEN** an explicit axis is outside the input dimensionality +- **THEN** the function raises NumPy's axis error + +#### Scenario: Scalar input is rejected +- **WHEN** `intervals_chain()` receives a zero-dimensional input +- **THEN** it raises `Not1DArrayException` + +#### Scenario: Empty sequence axis +- **WHEN** the selected input axis has length zero +- **THEN** the function returns an empty one-dimensional `numpy.intp` array + +### Requirement: Axis-aware interval chains are documented and benchmarked +The system MUST document the keyword-only axis, slice-as-element behavior, one-dimensional result shape, reconstruction-pipeline compatibility, and validation errors for core interval chains. The ASV suite MUST include time and peak-memory benchmarks for deterministic multidimensional records across representative sequence lengths, slice widths, and axis placements while retaining legacy one-dimensional coverage. + +#### Scenario: Public reference explains axis behavior +- **WHEN** a user opens the generated reference for `foapy.core.intervals_chain` +- **THEN** it includes runnable row or column examples and explains that the returned chain is one-dimensional along the selected axis + +#### Scenario: ASV discovers axis benchmarks +- **WHEN** the benchmark suite is collected +- **THEN** it includes deterministic time and peak-memory cases for core interval chains with multidimensional slice elements diff --git a/openspec/specs/axis-aware-sequence-factorization/spec.md b/openspec/specs/axis-aware-sequence-factorization/spec.md new file mode 100644 index 00000000..0d7f66a6 --- /dev/null +++ b/openspec/specs/axis-aware-sequence-factorization/spec.md @@ -0,0 +1,74 @@ +# Purpose + +Define axis-aware alphabet extraction and sequence ordering for dense arrays while preserving the legacy one-dimensional API. + +## Requirements + +### Requirement: Core alphabet supports slice elements along an axis +The system MUST provide `foapy.core.alphabet(X, *, axis=None)`. When `axis` is an integer, the function MUST treat the complete orthogonal slice at each position along that axis as one sequence element, return unique slice elements in order of first appearance, preserve the input rank, and replace only the selected axis length with the alphabet size. Both positive and equivalent negative axes MUST produce the same result. + +#### Scenario: One-dimensional scalar elements +- **WHEN** `alphabet(['b', 'a', 'b', 'c'], axis=0)` is called +- **THEN** it returns `['b', 'a', 'c']` + +#### Scenario: Rows are elements along axis zero +- **WHEN** a two-dimensional input has rows `R0`, `R1`, `R0` and `alphabet(X, axis=0)` is called +- **THEN** it returns the two-row array `R0`, `R1` with shape `(2, X.shape[1])` + +#### Scenario: Columns are elements along axis one +- **WHEN** a two-dimensional input has columns `C0`, `C1`, `C0`, `C2` and `alphabet(X, axis=1)` is called +- **THEN** it returns the three columns `C0`, `C1`, `C2` in that order and preserves axis 1 as the alphabet axis + +#### Scenario: Planes are elements in a three-dimensional input +- **WHEN** `alphabet()` receives a three-dimensional input and an explicit valid axis +- **THEN** each complete two-dimensional slice indexed along that axis is compared as one element and the result retains all orthogonal dimensions unchanged + +#### Scenario: First appearance controls alphabet order +- **WHEN** distinct slice elements have a lexicographic or numeric sort order different from their first occurrence order +- **THEN** the returned alphabet follows first occurrence order rather than sorted order + +### Requirement: Core order is the one-dimensional inverse of the alphabet +The system MUST provide `foapy.core.order(X, return_alphabet=False, *, axis=None)`. For an explicit axis, the returned order MUST be a one-dimensional `numpy.intp` array of length `X.shape[axis]`, and each value MUST be the zero-based first-appearance alphabet index of the complete slice at that axis position. When `return_alphabet=True`, the accompanying alphabet MUST equal `foapy.core.alphabet(X, axis=axis)`, and `numpy.take(alphabet, order, axis=axis)` MUST reconstruct the original dense input exactly. + +#### Scenario: Repeated rows produce repeated order indices +- **WHEN** a two-dimensional input has rows `R0`, `R1`, `R0` and `order(X, axis=0)` is called +- **THEN** the order is `[0, 1, 0]` + +#### Scenario: Repeated columns produce repeated order indices +- **WHEN** a two-dimensional input has columns `C0`, `C1`, `C0`, `C2` and `order(X, axis=1)` is called +- **THEN** the order is `[0, 1, 0, 2]` + +#### Scenario: Return alphabet uses the shared factorization +- **WHEN** `order(X, return_alphabet=True, axis=axis)` is called +- **THEN** it returns the same one-dimensional order as `order(X, axis=axis)` and the same alphabet as `alphabet(X, axis=axis)` + +#### Scenario: Dense reconstruction +- **WHEN** an order and alphabet are returned for any supported dense input and valid axis +- **THEN** `numpy.take(alphabet, order, axis=axis)` equals the original input in shape, dtype, and values + +#### Scenario: Empty sequence axis +- **WHEN** the selected input axis has length zero +- **THEN** order returns an empty `numpy.intp` array and alphabet returns an array whose selected axis has length zero and whose other dimensions match the input + +### Requirement: Axis behavior preserves the legacy one-dimensional API +Calls that omit `axis` MUST retain the existing one-dimensional behavior and return types of `foapy.core.alphabet` and `foapy.core.order`. A multidimensional input without an explicit axis MUST continue to raise `Not1DArrayException`; an explicit axis MUST be normalized using NumPy axis conventions, including negative axes, and an out-of-range axis MUST raise NumPy's axis error. Scalar inputs MUST be rejected as non-sequences. + +#### Scenario: Existing call without axis remains valid +- **WHEN** an existing caller invokes `alphabet(X)` or `order(X, return_alphabet)` with a one-dimensional input +- **THEN** the result is unchanged from the pre-axis API + +#### Scenario: Multidimensional input requires explicit intent +- **WHEN** a multidimensional input is passed without `axis` +- **THEN** the function raises `Not1DArrayException` + +#### Scenario: Negative axis is equivalent +- **WHEN** `axis=-1` and its equivalent positive axis select the same dimension +- **THEN** both calls return identical orders and alphabets + +#### Scenario: Axis is out of range +- **WHEN** an explicit axis is outside the input dimensionality +- **THEN** the function raises NumPy's axis error + +#### Scenario: Scalar input is rejected +- **WHEN** `alphabet()` or `order()` receives a zero-dimensional input +- **THEN** it raises `Not1DArrayException` diff --git a/openspec/specs/partials-package/spec.md b/openspec/specs/partials-package/spec.md index 9e9ecc3e..a084a0d4 100644 --- a/openspec/specs/partials-package/spec.md +++ b/openspec/specs/partials-package/spec.md @@ -5,103 +5,155 @@ Provide position-preserving FOA operations for partial masked sequences: gaps ar ## Requirements ### Requirement: Partial sequence ordering -The system MUST provide `foapy.partials.order(X, return_alphabet=False)`, accepting a 1-D masked array or a plain sequence treated as fully unmasked, and returning a 1-D masked integer array aligned to `X`. Non-masked positions MUST contain zero-based alphabet indices in first-appearance order, and masked positions MUST remain masked. The public API documentation MUST expose the function, describe its mask-preserving semantics and return modes, and include runnable examples for plain and masked inputs. The function MUST expose annotations for its input, boolean flag, and documented return forms without changing its callable interface. +The system MUST provide `foapy.partials.order(X, return_alphabet=False, *, axis=None)`, accepting a masked array or a plain sequence treated as fully unmasked. With no axis, it MUST retain its existing one-dimensional behavior. With an explicit axis, each complete orthogonal slice MUST be one sequence element, a fully masked slice MUST be a gap, and every slice MUST be either wholly masked or wholly unmasked. The result MUST be a one-dimensional masked integer array of length `X.shape[axis]`; non-gap positions MUST contain zero-based alphabet indices in first-appearance order and gap positions MUST remain masked. When requested, the alphabet MUST be a plain array of the unique non-gap slices, retain the selected axis, and equal `foapy.partials.alphabet(X, axis=axis)`. The public API documentation and annotations MUST describe both call modes, shapes, return forms, mask rules, and reconstruction of observed slices. -#### Scenario: Order preserves gaps +#### Scenario: One-dimensional order preserves gaps - **WHEN** `order()` receives `['a', --, 'b', 'a', --]` - **THEN** it returns `[0, --, 1, 0, --]` with the same length and mask -#### Scenario: Order returns an alphabet when requested -- **WHEN** `order()` is called with `return_alphabet=True` -- **THEN** it returns the masked order array and a plain alphabet array containing only non-masked unique values in first-appearance order +#### Scenario: Whole masked slices are gaps +- **WHEN** a multidimensional partial input contains slice `S0`, a wholly masked slice, `S1`, and `S0` along the selected axis +- **THEN** order returns `[0, --, 1, 0]` + +#### Scenario: Order returns an axis-preserving alphabet when requested +- **WHEN** multidimensional `order()` is called with `return_alphabet=True` and an explicit axis +- **THEN** it returns the one-dimensional masked order and a plain alphabet array containing only unique non-gap slices in first-appearance order, with the alphabet dimension at the selected axis + +#### Scenario: Observed slices can be reconstructed +- **WHEN** the returned alphabet is indexed along the selected axis by the unmasked order values +- **THEN** every non-gap source slice is reconstructed exactly and broadcasting the order mask across the orthogonal dimensions restores the source gap mask #### Scenario: Empty or fully masked input -- **WHEN** `order()` receives an empty or fully masked 1-D input -- **THEN** it returns a same-length fully masked order array, and an empty alphabet when requested +- **WHEN** `order()` receives an empty or fully masked input with a valid sequence axis +- **THEN** it returns a same-axis-length fully masked order array and an axis-preserving empty alphabet when requested + +#### Scenario: Mixed mask inside one slice is rejected +- **WHEN** any slice along the selected axis contains both masked and unmasked scalar components +- **THEN** `order()` raises `ValueError` because the slice does not define one present or absent sequence element -#### Scenario: Multi-dimensional input is rejected -- **WHEN** `order()` receives an input with more than one dimension +#### Scenario: Multidimensional input without axis is rejected +- **WHEN** `order()` receives an input with more than one dimension and no explicit axis - **THEN** it raises `Not1DArrayException` #### Scenario: Public documentation provides runnable examples - **WHEN** a user opens the generated reference for `foapy.partials.order` -- **THEN** the reference includes examples for plain input, masked input with preserved gaps, and `return_alphabet=True` +- **THEN** the reference includes runnable examples for plain input, one-dimensional gaps, multidimensional whole-slice gaps, `return_alphabet=True`, and reconstruction #### Scenario: Signature annotations describe the contract - **WHEN** a caller inspects `foapy.partials.order` -- **THEN** annotations identify the accepted array-like input, boolean `return_alphabet` flag, and masked-array or tuple return forms without requiring different call syntax +- **THEN** annotations identify the accepted array-like input, boolean `return_alphabet` flag, optional integer axis, and masked-array or tuple return forms #### Scenario: ASV benchmark coverage exists - **WHEN** the ASV benchmark suite discovers partials benchmarks -- **THEN** it includes time and peak-memory cases for `foapy.partials.order` across scalable input lengths and representative unmasked, partially masked, and fully masked data +- **THEN** it includes time and peak-memory cases for `foapy.partials.order` across scalable sequence-axis lengths and representative one-dimensional, multidimensional, unmasked, partially gapped, and fully gapped data ### Requirement: Partial sequence alphabet extraction -The system MUST provide `foapy.partials.alphabet(X)`, accepting a 1-D masked array or plain sequence and returning a plain 1-D `numpy.ndarray` of unique non-masked values in first-appearance order. Its public documentation MUST describe masked-value exclusion, empty and fully masked inputs, dimensionality errors, and runnable usage examples. The function MUST expose type annotations for its input and plain-array return value without changing its callable interface. +The system MUST provide `foapy.partials.alphabet(X, *, axis=None)`, accepting a masked array or plain sequence. With no axis, it MUST retain its existing one-dimensional behavior. With an explicit axis, each complete orthogonal slice MUST be one sequence element, fully masked slices MUST be excluded as gaps, and every slice MUST be either wholly masked or wholly unmasked. The function MUST return a plain `numpy.ndarray` containing unique non-gap slices in first-appearance order, preserving the input rank and replacing only the selected axis length with the alphabet size. Its public documentation and annotations MUST describe masked-slice exclusion, axis and shape behavior, empty and fully masked inputs, validation errors, and runnable examples. -#### Scenario: Masked values are excluded +#### Scenario: One-dimensional masked values are excluded - **WHEN** `alphabet()` receives `['a', --, 'b', 'a', --]` -- **THEN** it returns `['a', 'b']` as a plain 1-D array +- **THEN** it returns `['a', 'b']` as a plain one-dimensional array -#### Scenario: First element is masked -- **WHEN** `alphabet()` receives `[--, 'b', 'a']` -- **THEN** it returns `['b', 'a']` and does not treat the masked first position as an alphabet value +#### Scenario: Fully masked multidimensional slices are excluded +- **WHEN** slices along the selected axis are `S0`, a wholly masked slice, `S1`, and `S0` +- **THEN** `alphabet()` returns `S0`, `S1` as a plain array with the selected axis reduced to length two + +#### Scenario: First slice is masked +- **WHEN** the first slice is wholly masked and later non-gap slices are `S1`, `S0` +- **THEN** the alphabet is `S1`, `S0` and the masked first position does not affect first-appearance order #### Scenario: First occurrence is masked and later occurrence is unmasked -- **WHEN** `alphabet()` receives `[--, 'a', 'b', 'a']` where the first `a` position is masked -- **THEN** it returns `['b', 'a']`, ordering values by their first unmasked occurrence +- **WHEN** data under a wholly masked slice equals a later non-gap slice +- **THEN** only the later non-gap occurrence introduces that slice into the alphabet #### Scenario: Fully masked or empty input -- **WHEN** `alphabet()` receives a fully masked or empty 1-D input -- **THEN** it returns an empty plain array +- **WHEN** `alphabet()` receives a fully masked input or an input whose selected axis is empty +- **THEN** it returns a plain array with length zero on the selected axis and all orthogonal dimensions preserved + +#### Scenario: Mixed mask inside one slice is rejected +- **WHEN** any slice along the selected axis contains both masked and unmasked scalar components +- **THEN** `alphabet()` raises `ValueError` -#### Scenario: Multi-dimensional input is rejected -- **WHEN** `alphabet()` receives an input with more than one dimension +#### Scenario: Multidimensional input without axis is rejected +- **WHEN** `alphabet()` receives a multidimensional input without an explicit axis - **THEN** it raises `Not1DArrayException` ### Requirement: Position-preserving partial interval chains -The system MUST provide `foapy.partials.intervals_chain(X, binding, chain_mode)`, accepting the raw 1-D masked sequence (or a plain fully unmasked sequence), and returning a same-length masked integer array. Non-masked values MUST contain interval distances calculated from actual source indices; masked positions MUST remain masked. Inputs whose normalized array dimensionality is not exactly one MUST raise `Not1DArrayException`. +The system MUST provide `foapy.partials.intervals_chain(X, binding, chain_mode, *, axis=None)`, accepting a masked array or a plain sequence treated as fully unmasked. With no axis, it MUST retain its existing one-dimensional behavior. With an explicit axis, each complete orthogonal slice MUST be one sequence element, a fully masked slice MUST be a gap, and every slice MUST be either wholly masked or wholly unmasked. The result MUST be a one-dimensional masked `numpy.intp` array of length `X.shape[axis]`; non-gap positions MUST contain interval distances calculated from actual selected-axis indices and gap positions MUST remain masked. Gaps MUST count toward occurrence, boundary, and cyclic distances because they remain positions in the selected-axis coordinate system. -#### Scenario: Gaps count toward distance +#### Scenario: One-dimensional gaps count toward distance - **WHEN** `intervals_chain()` receives `[--, C, T, C, --, G]` with start binding and boundary mode - **THEN** it returns `[--, 2, 3, 2, --, 6]` +#### Scenario: Whole masked slices are positional gaps +- **WHEN** multidimensional slices along the selected axis are `S0`, a wholly masked slice, `S1`, and `S0` with start binding and boundary mode +- **THEN** the function returns `[1, --, 3, 3]` + +#### Scenario: Cycle mode includes whole-slice gaps +- **WHEN** multidimensional slices along the selected axis are `S0`, a wholly masked slice, `S1`, and `S0` with start binding and cycle mode +- **THEN** the function returns `[1, --, 4, 3]`, using the full selected-axis length of four + #### Scenario: Dense input matches core -- **WHEN** `intervals_chain()` receives a 1-D input with no masked positions -- **THEN** its non-masked result equals `foapy.core.intervals_chain()` for the same input and modes +- **WHEN** `intervals_chain()` receives a plain or fully unmasked input with any valid explicit axis +- **THEN** its non-masked values equal `foapy.core.intervals_chain()` for the same input, binding, chain mode, and axis #### Scenario: Fully masked input -- **WHEN** `intervals_chain()` receives a fully masked input -- **THEN** it returns a fully masked array of the same length +- **WHEN** every slice along a nonempty selected axis is wholly masked +- **THEN** the function returns a fully masked one-dimensional array with the selected-axis length + +#### Scenario: Empty sequence axis +- **WHEN** the selected input axis has length zero +- **THEN** the function returns an empty one-dimensional masked `numpy.intp` array + +#### Scenario: Mixed mask inside one slice is rejected +- **WHEN** any slice along the selected axis contains both masked and unmasked scalar components +- **THEN** `intervals_chain()` raises `ValueError` because the slice does not define one present or absent sequence element -#### Scenario: Scalar input is rejected consistently -- **WHEN** `intervals_chain()` receives a scalar or other 0-D input -- **THEN** it raises `Not1DArrayException`, matching `foapy.core.intervals_chain()` +#### Scenario: Multidimensional input without axis is rejected +- **WHEN** `intervals_chain()` receives a multidimensional input without an explicit axis +- **THEN** it raises `Not1DArrayException` + +#### Scenario: Negative axis is equivalent +- **WHEN** a negative and equivalent positive axis select the same dimension +- **THEN** both calls return interval chains with identical data and masks -#### Scenario: Multidimensional input or invalid modes are rejected -- **WHEN** `intervals_chain()` receives a multi-dimensional input or an unsupported binding or chain mode -- **THEN** it raises `Not1DArrayException` for the dimensionality error or `ValueError` for the invalid mode +#### Scenario: Invalid axis or scalar input is rejected consistently +- **WHEN** the explicit axis is out of range or the input is zero-dimensional +- **THEN** the function raises NumPy's axis error for the invalid axis or `Not1DArrayException` for the scalar + +#### Scenario: Invalid modes are rejected +- **WHEN** `intervals_chain()` receives an unsupported binding or chain mode +- **THEN** it raises `ValueError` ### Requirement: Published partial intervals-chain reference -The documentation MUST publish an API reference for `foapy.partials.intervals_chain` under the `foapy.partials` reference navigation. The reference MUST describe its shared signature, masked-array return type, mask preservation, source-position gap semantics, errors, and at least one runnable dense or masked example. +The documentation MUST publish an API reference for `foapy.partials.intervals_chain` under the `foapy.partials` reference navigation. The reference MUST describe its keyword-only axis, slice-as-element behavior, one-dimensional masked return shape, whole-slice mask rules, source-position gap semantics, errors, and runnable one-dimensional and multidimensional examples. #### Scenario: Partials API page is discoverable - **WHEN** a user browses the generated documentation's `foapy.partials` reference section - **THEN** an `intervals_chain` entry links to the API reference page -#### Scenario: Reference explains the semantic difference +#### Scenario: Reference explains gap and axis semantics - **WHEN** a user reads the partials intervals-chain reference -- **THEN** it explains that masked positions remain in the output and count toward distances, and distinguishes this from the dense/core and compressed `foapy.ma` behavior +- **THEN** it explains that wholly masked slices remain masked positions, count toward distances along the selected axis, and differ from compressed semantics + +#### Scenario: Reference includes a multidimensional example +- **WHEN** a user reads the partial intervals-chain reference +- **THEN** it includes a runnable example with whole-slice gaps and an explicit axis ### Requirement: Partials intervals-chain benchmark coverage -The benchmark suite MUST measure `foapy.partials.intervals_chain` execution time and peak memory for representative input lengths of 100, 10,000, and 1,000,000, with both bindings and both chain modes. At least one benchmark input MUST contain masked gaps. +The benchmark suite MUST measure `foapy.partials.intervals_chain` execution time and peak memory for representative one-dimensional lengths and for deterministic multidimensional records across multiple sequence-axis lengths, slice widths, axis placements, and whole-slice mask states. The matrix MUST retain both bindings and both chain modes, and at least one axis-aware benchmark input MUST contain whole-slice gaps. -#### Scenario: Benchmark suite covers the parameter matrix +#### Scenario: Benchmark suite covers the legacy parameter matrix - **WHEN** the partials intervals-chain benchmark suite is collected -- **THEN** it exposes length, dataset, binding, and chain-mode parameters covering the required sizes and both enum values +- **THEN** it retains length, dataset, binding, and chain-mode parameters covering the required one-dimensional sizes and both enum values -#### Scenario: Benchmark exercises masked semantics -- **WHEN** the benchmark invokes the partials intervals-chain methods on a gapped dataset -- **THEN** it passes a masked input and measures the function call rather than silently benchmarking the core or compressed implementation +#### Scenario: Benchmark suite covers axis inputs +- **WHEN** the partials intervals-chain benchmark suite is collected +- **THEN** it includes time and peak-memory cases parameterized by sequence-axis length, record width, axis placement, whole-slice mask state, binding, and chain mode + +#### Scenario: Benchmark exercises whole-slice gap semantics +- **WHEN** an axis-aware partial benchmark invokes `intervals_chain` +- **THEN** at least one case passes a masked multidimensional input with wholly masked slices rather than compressing or preprocessing it outside the timed call ### Requirement: Partial interval tuple strategies The system MUST provide `foapy.partials.intervals_tuple(chain, binding, tuple_mode)`, accepting a 1-D masked interval chain (as produced by `foapy.partials.intervals_chain`) or a plain fully unmasked chain, and returning a plain 1-D `numpy.ndarray` of dtype `numpy.intp` with masked (gap) positions excluded entirely. The function MUST NOT return a `numpy.ma.MaskedArray`. For `binding.end`, the returned order MUST match `foapy.core.intervals_tuple`'s own ordering convention (the reversed processing frame), not the source array's left-to-right order. @@ -133,16 +185,24 @@ The system MUST provide `foapy.partials.intervals_tuple(chain, binding, tuple_mo - **THEN** it raises `ValueError` ### Requirement: Package boundary and dense parity -The system MUST expose exactly `order`, `alphabet`, `intervals_chain`, and `intervals_tuple` from `foapy.partials`, preserve masks through all positional operations, and leave `foapy.core` and `foapy.ma` behavior unchanged. +The system MUST expose exactly `order`, `alphabet`, `intervals_chain`, and `intervals_tuple` from `foapy.partials`. For one-dimensional inputs and for wholly present slice elements along an explicit axis, partial alphabet, order, and interval-chain results MUST match their corresponding core results, subject to the documented masked-array representation. Existing partial interval behavior and top-level package exports MUST remain unchanged. #### Scenario: Submodule-only access - **WHEN** a caller imports `foapy.partials` - **THEN** the four partials functions are available from that submodule and are not added as top-level `foapy` functions -#### Scenario: Zero-gap parity across the pipeline -- **WHEN** a caller uses an unmasked input and valid combinations of binding, chain mode, and tuple mode +#### Scenario: Zero-gap parity for alphabet and order +- **WHEN** a caller uses a plain or fully unmasked input with any valid explicit axis +- **THEN** the partial alphabet equals the core alphabet and the non-masked values of the partial order equal the core order + +#### Scenario: Zero-gap parity for interval chains +- **WHEN** a caller uses a plain or fully unmasked input with any valid explicit axis, binding, and chain mode +- **THEN** the partial interval-chain mask is empty and its values equal the core interval chain + +#### Scenario: Existing one-dimensional pipeline parity +- **WHEN** a caller uses an unmasked one-dimensional input and valid combinations of binding, chain mode, and tuple mode - **THEN** partials results match the corresponding core results, subject to the documented masked-array representation -#### Scenario: Gap masks survive the pipeline -- **WHEN** a caller passes a partially masked sequence through ordering, interval-chain, and tuple operations -- **THEN** every source gap remains masked in each positional output +#### Scenario: Existing interval gap behavior remains unchanged +- **WHEN** a caller passes a one-dimensional partial sequence through ordering, interval-chain, and tuple operations +- **THEN** every source gap retains the behavior documented by the partial interval requirements diff --git a/setup.cfg b/setup.cfg index d812ac29..463a87a9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,7 @@ package_dir = install_requires = importlib-metadata; python_version<"3.8" numpy>=1.20 + xxhash>=2.0.0 [options.packages.find] where = src diff --git a/src/foapy/core/_alphabet.py b/src/foapy/core/_alphabet.py index 637f9e96..e0ff8789 100644 --- a/src/foapy/core/_alphabet.py +++ b/src/foapy/core/_alphabet.py @@ -1,12 +1,15 @@ +from typing import Optional + import numpy as np from numpy import ndarray +from numpy.typing import ArrayLike -from foapy.exceptions import Not1DArrayException +from foapy.core._factorize import _normalize_sequence_axis, _stable_factorize -def alphabet(X) -> ndarray: +def alphabet(X: ArrayLike, *, axis: Optional[int] = None) -> ndarray: """ - Get an alphabet - a list of unique values from an array in order of their first appearance. + Get unique sequence elements in order of their first appearance. The alphabet is constructed by scanning the input array from left to right and adding each new unique value encountered. This preserves the order of the first appearance of each element, which @@ -22,17 +25,24 @@ def alphabet(X) -> ndarray: Parameters ---------- X : array_like - Array to extract an alphabet from. Must be a 1-dimensional array. + Sequence to factorize. With an explicit ``axis``, each complete + orthogonal slice indexed along that axis is one sequence element. + axis : int, optional + Sequence axis. If omitted, ``X`` must be 1-dimensional. Negative + axes follow NumPy conventions. Returns ------- : ndarray - Alphabet of X - array of unique values in order of their first appearance + Unique elements in first-appearance order. For an explicit axis, the + result has the same rank as ``X`` and only that axis changes length. Raises ------- Not1DArrayException - When X parameter is not a 1-dimensional array + When ``X`` is scalar, or is multidimensional without an explicit axis. + numpy.exceptions.AxisError + When an explicit axis is out of range. Examples -------- @@ -57,26 +67,53 @@ def alphabet(X) -> ndarray: # [] ``` - Getting an alphabet from an array with more than 1 dimension is not allowed: + Rows can be treated as complete elements by selecting axis 0: ``` py linenums="1" + import numpy as np import foapy - source = [[[1], [3]], [[6], [9]], [[6], [3]]] - alphabet = foapy.alphabet(source) - # Not1DArrayException: {'message': 'Incorrect array form. Expected d1 array, exists 3'} + source = np.array([[2, 1], [3, 4], [2, 1]]) + result = foapy.alphabet(source, axis=0) + print(result) + # [[2 1] + # [3 4]] ``` + + For a 3-dimensional input, selecting axis 1 treats ``source[:, i, :]`` + as element ``i``. Equivalent negative axes are accepted: + + ``` py linenums="1" + import numpy as np + import foapy + + source = np.array( + [ + [[1, 2], [3, 4], [1, 2]], + [[5, 6], [7, 8], [5, 6]], + ] + ) + result = foapy.alphabet(source, axis=-2) + print(result.shape) + # (2, 2, 2) + ``` + + See :func:`foapy.order` for reconstruction with ``numpy.take``. """ # noqa: E501 data = np.asanyarray(X) - if data.ndim > 1: # Checking for d1 array - raise Not1DArrayException( - {"message": f"Incorrect array form. Expected d1 array, exists {data.ndim}"} - ) + if data.ndim != 1: + _, result = _stable_factorize(data, axis=axis) + return result + + if axis is not None: + _normalize_sequence_axis(data, axis) + + # Keep the legacy scalar-element path independent from order: computing + # an inverse mapping roughly doubled its work and memory use. perm = data.argsort(kind="mergesort") - mask_shape = data.shape - unique_mask = np.empty(mask_shape, dtype=bool) + unique_mask = np.empty(data.shape, dtype=bool) unique_mask[:1] = True unique_mask[1:] = data[perm[1:]] != data[perm[:-1]] diff --git a/src/foapy/core/_axis_transform.py b/src/foapy/core/_axis_transform.py new file mode 100644 index 00000000..ab72a2a0 --- /dev/null +++ b/src/foapy/core/_axis_transform.py @@ -0,0 +1,83 @@ +from typing import Callable, Optional, Tuple + +import numpy as np +import numpy.ma as ma +from numpy import ndarray + +from foapy.core._factorize import _normalize_sequence_axis + + +def _axis_lanes( + data: ndarray, axis: Optional[int] +) -> Tuple[int, Tuple[int, ...], ndarray]: + """Return flattened 1-D lanes with the selected axis moved last.""" + normalized_axis = _normalize_sequence_axis(data, axis) + outer_shape, lanes = _axis_lanes_for_normalized_axis(data, normalized_axis) + return normalized_axis, outer_shape, lanes + + +def _axis_lanes_for_normalized_axis( + data: ndarray, normalized_axis: int +) -> Tuple[Tuple[int, ...], ndarray]: + """Return flattened lanes for an axis that was already normalized.""" + moved = np.moveaxis(data, normalized_axis, -1) + outer_shape = moved.shape[:-1] + lane_count = int(np.prod(outer_shape, dtype=np.intp)) if outer_shape else 1 + lanes = moved.reshape((lane_count, moved.shape[-1])) + return outer_shape, lanes + + +def _apply_to_axis_lanes( + data: ndarray, + axis: Optional[int], + function: Callable[[ndarray], ndarray], + *, + normalized_axis: Optional[int] = None, +) -> ma.MaskedArray: + """Apply one vectorized function to the complete selected-axis lane batch.""" + if normalized_axis is None: + normalized_axis, outer_shape, lanes = _axis_lanes(data, axis) + else: + outer_shape, lanes = _axis_lanes_for_normalized_axis(data, normalized_axis) + results = ma.asarray(function(lanes), dtype=np.intp) + moved_result = results.reshape(outer_shape + (results.shape[1],)) + return np.moveaxis(moved_result, -1, normalized_axis) + + +def _pack_axis_lane_values( + values: ndarray, + selected: ndarray, + *, + prefix: Optional[ndarray] = None, +) -> ma.MaskedArray: + """Pack selected values after an optional fixed-width prefix per row.""" + selected = np.asarray(selected, dtype=bool) + counts = np.count_nonzero(selected, axis=1) + packed_length = int(np.max(counts, initial=0)) + + if prefix is not None: + prefix = np.asarray(prefix, dtype=np.intp) + prefix_length = prefix.shape[1] + result_values = np.empty( + (selected.shape[0], prefix_length + packed_length), dtype=np.intp + ) + result_mask = np.empty(result_values.shape, dtype=bool) + result_values[:, :prefix_length] = prefix + result_mask[:, :prefix_length] = False + packed_mask = result_mask[:, prefix_length:] + np.greater_equal( + np.arange(packed_length, dtype=np.intp)[None, :], + counts[:, None], + out=packed_mask, + ) + result_values[:, prefix_length:][~packed_mask] = np.broadcast_to( + np.asarray(values, dtype=np.intp), selected.shape + )[selected] + return ma.masked_array(result_values, mask=result_mask) + + packed_mask = np.arange(packed_length, dtype=np.intp)[None, :] >= counts[:, None] + packed_values = np.zeros((selected.shape[0], packed_length), dtype=np.intp) + packed_values[~packed_mask] = np.broadcast_to( + np.asarray(values, dtype=np.intp), selected.shape + )[selected] + return ma.masked_array(packed_values, mask=packed_mask) diff --git a/src/foapy/core/_factorize.py b/src/foapy/core/_factorize.py new file mode 100644 index 00000000..7f336cd1 --- /dev/null +++ b/src/foapy/core/_factorize.py @@ -0,0 +1,194 @@ +from typing import Optional, Tuple + +import numpy as np +import xxhash +from numpy import ndarray +from numpy.typing import ArrayLike + +from foapy.exceptions import Not1DArrayException + +try: + from numpy.lib.array_utils import normalize_axis_index +except ImportError: # NumPy < 2.0 + from numpy.core.multiarray import normalize_axis_index + + +_HASH_MIN_RECORD_BYTES = 512 + + +def _normalize_sequence_axis(data: ndarray, axis: Optional[int]) -> int: + """Validate an input sequence and return its normalized sequence axis.""" + if data.ndim == 0: + raise Not1DArrayException( + {"message": "Incorrect array form. Expected d1 array, exists 0"} + ) + + if axis is None: + if data.ndim != 1: + raise Not1DArrayException( + { + "message": ( + "Incorrect array form. Expected d1 array, " + f"exists {data.ndim}" + ) + } + ) + return 0 + + return normalize_axis_index(axis, data.ndim) + + +def _digest_record(record_bytes: ndarray) -> np.void: + """Return the XXH3-128 digest for one contiguous byte record.""" + return np.void(xxhash.xxh3_128_digest(record_bytes)) + + +def _digest_records(record_bytes: ndarray) -> ndarray: + """Return one XXH3-128 digest for every byte record. + + NOTE: benchmarking (n_records up to 500k, record sizes 64-1024 bytes) + showed this plain loop beats both `numpy.apply_along_axis` and + `numpy.vectorize` on time and memory, since `xxhash` has no numpy ufunc + to vectorize onto and both alternatives are themselves Python loops with + extra overhead. A Cython extension calling `XXH3_128bits` directly in a + nogil loop measured 2-12x faster still (larger win on smaller records) - + candidate for migration if this becomes a hot path, pending constitution + approval for a compiled-extension / build-time dependency. + """ + sequence_length = record_bytes.shape[0] + digests = np.empty(sequence_length, dtype="V16") + for i in range(sequence_length): + digests[i] = _digest_record(record_bytes[i]) + return digests + + +def _slice_digests(moved: ndarray) -> Optional[ndarray]: + """Build equality-compatible digest candidates for plain slice dtypes.""" + if moved.dtype.fields is not None or moved.dtype.kind not in "biufcmMSU": + return None + + if moved[0].nbytes < _HASH_MIN_RECORD_BYTES: + return None + + sequence_length = moved.shape[0] + records = np.ascontiguousarray(moved.reshape(sequence_length, -1)) + + # NumPy equality considers signed zeroes equal, while their byte strings + # differ. Normalize only when needed so ordinary arrays remain views. + if records.dtype.kind == "f": + signed_zero = (records == 0) & np.signbit(records) + if np.any(signed_zero): + if np.may_share_memory(records, moved) or not records.flags.writeable: + records = records.copy() + records[signed_zero] = 0 + elif records.dtype.kind == "c": + signed_real_zero = (records.real == 0) & np.signbit(records.real) + signed_imag_zero = (records.imag == 0) & np.signbit(records.imag) + if np.any(signed_real_zero) or np.any(signed_imag_zero): + if np.may_share_memory(records, moved) or not records.flags.writeable: + records = records.copy() + records.real[signed_real_zero] = 0 + records.imag[signed_imag_zero] = 0 + + record_bytes = records.view(np.uint8).reshape(sequence_length, -1) + return _digest_records(record_bytes) + + +def _factorize_digest_candidates( + digests: ndarray, moved: ndarray +) -> Optional[Tuple[ndarray, ndarray]]: + """Factorize verified digest groups, or signal an exact fallback.""" + sequence_length = digests.size + + # Sorting makes every equal-digest group contiguous. ``unique_mask`` marks + # the first record in each group; a false value therefore means that this + # record and its predecessor have the same digest. + perm = digests.argsort(kind="mergesort") + sorted_digests = digests[perm] + + unique_mask = np.empty(sequence_length, dtype=bool) + unique_mask[:1] = True + unique_mask[1:] = sorted_digests[1:] != sorted_digests[:-1] + + # A digest match is only a candidate equality. Compare every adjacent pair + # inside an equal-digest group across all fields of the original slices. + # If a group contains two different slices, at least one adjacent pair is + # unequal, so this detects a real hash collision without a Python loop. + repeated = ~unique_mask[1:] + if np.any(repeated): + sorted_slices = np.take(moved, perm, axis=0) + adjacent_equal = np.all( + sorted_slices[1:] == sorted_slices[:-1], + axis=tuple(range(1, sorted_slices.ndim)), + ) + if np.any(repeated & ~adjacent_equal): + # Returning None discards every hash-derived result. The caller + # reruns exact NumPy factorization, so collisions cannot merge + # distinct alphabet elements. + return None + + alphabet_mask = np.zeros(sequence_length, dtype=bool) + alphabet_mask[perm[unique_mask]] = True + power = np.count_nonzero(unique_mask) + + sorted_order = np.cumsum(unique_mask, dtype=np.intp) - 1 + sorted_inverse = np.empty(sequence_length, dtype=np.intp) + sorted_inverse[perm] = sorted_order + + stable_to_sorted = sorted_inverse[alphabet_mask] + sorted_to_stable = np.empty(power, dtype=np.intp) + sorted_to_stable[stable_to_sorted] = np.arange(power, dtype=np.intp) + order = sorted_to_stable[sorted_inverse] + + return order, alphabet_mask + + +def _factorize_unique_slices(data: ndarray, axis: int) -> Tuple[ndarray, ndarray]: + """Factorize slices exactly through NumPy's structured-record path.""" + sorted_alphabet, first_indices, sorted_inverse = np.unique( + data, + return_index=True, + return_inverse=True, + axis=axis, + ) + + stable_to_sorted = np.argsort(first_indices, kind="stable") + alphabet = np.take(sorted_alphabet, stable_to_sorted, axis=axis) + + sorted_to_stable = np.empty(first_indices.size, dtype=np.intp) + sorted_to_stable[stable_to_sorted] = np.arange(first_indices.size, dtype=np.intp) + order = sorted_to_stable[np.asarray(sorted_inverse).reshape(-1)] + + return order, alphabet + + +def _stable_factorize( + X: ArrayLike, axis: Optional[int] = None +) -> Tuple[ndarray, ndarray]: + """Factorize multidimensional slice elements in first-appearance order.""" + data = np.asanyarray(X) + normalized_axis = _normalize_sequence_axis(data, axis) + + moved = np.moveaxis(data, normalized_axis, 0) + sequence_length = moved.shape[0] + + if sequence_length == 0: + return np.array([], dtype=np.intp), data.copy() + + # With no scalar fields every orthogonal slice is the same empty element. + if moved[0].size == 0: + order = np.zeros(sequence_length, dtype=np.intp) + alphabet = np.take(data, [0], axis=normalized_axis) + return order, alphabet + + digests = _slice_digests(moved) + if digests is not None: + factorization = _factorize_digest_candidates(digests, moved) + if factorization is not None: + order, alphabet_mask = factorization + alphabet = np.compress(alphabet_mask, data, axis=normalized_axis) + return order, alphabet + + # Unsupported dtypes, equality/byte-semantic mismatches (such as NaNs), + # and detected XXH3 collisions all take this exact path. + return _factorize_unique_slices(data, normalized_axis) diff --git a/src/foapy/core/_intervals_chain.py b/src/foapy/core/_intervals_chain.py index 0be9fffc..c5b23e26 100644 --- a/src/foapy/core/_intervals_chain.py +++ b/src/foapy/core/_intervals_chain.py @@ -1,12 +1,22 @@ +from typing import Optional + import numpy as np from numpy import ndarray +from numpy.typing import ArrayLike from foapy.core._binding import binding as binding_cls from foapy.core._chain_mode import chain_mode as chain_mode_cls -from foapy.exceptions import Not1DArrayException +from foapy.core._factorize import _normalize_sequence_axis +from foapy.core._order import order as core_order -def intervals_chain(X, binding: int, chain_mode: int) -> ndarray: +def intervals_chain( + X: ArrayLike, + binding: int, + chain_mode: int, + *, + axis: Optional[int] = None, +) -> ndarray: """ Compute the raw intervals chain from a sequence. @@ -19,8 +29,9 @@ def intervals_chain(X, binding: int, chain_mode: int) -> ndarray: Parameters ---------- X : array_like - Input sequence (strings, integers, or any comparable elements). - Must be 1-dimensional. No pre-ordering via ``order()`` is required. + Input sequence. With an explicit ``axis``, each complete orthogonal + slice indexed along that axis is one sequence element. No pre-ordering + via :func:`foapy.order` is required. binding : int ``binding.start`` (1) — intervals extracted left-to-right. ``binding.end`` (2) — intervals extracted right-to-left. @@ -29,18 +40,25 @@ def intervals_chain(X, binding: int, chain_mode: int) -> ndarray: intervals are distances from sequence edges to first/last occurrence. ``chain_mode.cycle`` (2) — sequence treated as circular; leading and trailing boundary distances are combined into a single cyclic interval. + axis : int, optional + Sequence axis. If omitted, ``X`` must be one-dimensional. Negative + axes follow NumPy conventions. Returns ------- ndarray - Plain 1-D ndarray of dtype ``intp`` containing the raw interval chain - in original sequence order. All values are positive integers ≥ 1 and - ≤ ``len(X)``. + Plain one-dimensional ndarray of dtype ``intp`` containing one raw + interval per sequence element in original selected-axis order. Its + length is ``X.shape[axis]`` for an explicit axis, or ``len(X)`` for a + legacy one-dimensional call. All values are positive integers ≥ 1 and + no greater than the sequence-axis length. Raises ------ Not1DArrayException - When ``X`` is not a 1-dimensional array. + When ``X`` is scalar, or is multidimensional without an explicit axis. + numpy.exceptions.AxisError + When an explicit axis is out of range. ValueError When ``binding`` is not ``binding.start`` or ``binding.end``. When ``chain_mode`` is not ``chain_mode.boundary`` or ``chain_mode.cycle``. @@ -71,6 +89,40 @@ def intervals_chain(X, binding: int, chain_mode: int) -> ndarray: chain = foapy.intervals_chain(source, foapy.binding.start, foapy.chain_mode.boundary) # noqa: E501 print(chain) # [] ``` + + Treat complete rows as sequence elements. The returned chain is still + one-dimensional and can be passed directly to :func:`foapy.intervals_tuple`: + + ``` py linenums="1" + import numpy as np + import foapy + + source = np.array([[1, 2], [3, 4], [1, 2], [5, 6], [1, 2]]) + chain = foapy.intervals_chain( + source, + foapy.binding.start, + foapy.chain_mode.boundary, + axis=0, + ) + print(chain) # [1 2 2 4 2] + ``` + + Columns can be selected in the same way, including through an equivalent + negative axis: + + ``` py linenums="1" + import numpy as np + import foapy + + source = np.array([[1, 3, 1, 5], [2, 4, 2, 6]]) + chain = foapy.intervals_chain( + source, + foapy.binding.start, + foapy.chain_mode.boundary, + axis=-1, + ) + print(chain) # [1 2 2 4] + ``` """ if binding not in {binding_cls.start, binding_cls.end}: raise ValueError( @@ -87,12 +139,20 @@ def intervals_chain(X, binding: int, chain_mode: int) -> ndarray: } ) - ar = np.asanyarray(X) + data = np.asanyarray(X) - if ar.ndim != 1: - raise Not1DArrayException( - {"message": (f"Incorrect array form. Expected d1 array, exists {ar.ndim}")} - ) + if data.ndim != 1: + sequence_order = core_order(data, axis=axis) + return _intervals_chain_1d(sequence_order, binding, chain_mode) + + if axis is not None: + _normalize_sequence_axis(data, axis) + + return _intervals_chain_1d(data, binding, chain_mode) + + +def _intervals_chain_1d(ar: ndarray, binding: int, chain_mode: int) -> ndarray: + """Compute an interval chain for an already validated 1-D sequence.""" if ar.shape[0] == 0: return np.array([], dtype=np.intp) diff --git a/src/foapy/core/_intervals_chain_validation.py b/src/foapy/core/_intervals_chain_validation.py new file mode 100644 index 00000000..60493758 --- /dev/null +++ b/src/foapy/core/_intervals_chain_validation.py @@ -0,0 +1,31 @@ +from typing import Optional + +import numpy as np +from numpy.typing import ArrayLike + +from foapy.core._axis_transform import _axis_lanes +from foapy.core._factorize import _normalize_sequence_axis + + +def _are_valid_intervals_chain_lanes(lanes: np.ndarray) -> bool: + """Validate a prepared lane batch; semantic checks will be added later.""" + return True + + +def is_valid_intervals_chain(chain: ArrayLike, *, axis: Optional[int] = None) -> bool: + """Return whether every selected-axis lane is a valid interval chain.""" + if isinstance(chain, np.ndarray) and axis is None and chain.ndim == 1: + return True + + data = chain if isinstance(chain, np.ndarray) else np.asanyarray(chain) + + if data.ndim == 1: + if axis is not None: + _normalize_sequence_axis(data, axis) + return True + + if data.ndim == 2 and axis in {1, -1}: + return bool(_are_valid_intervals_chain_lanes(data)) + + _, _, lanes = _axis_lanes(data, axis) + return bool(_are_valid_intervals_chain_lanes(lanes)) diff --git a/src/foapy/core/_intervals_distribution.py b/src/foapy/core/_intervals_distribution.py index 3c190916..a9429d40 100644 --- a/src/foapy/core/_intervals_distribution.py +++ b/src/foapy/core/_intervals_distribution.py @@ -1,28 +1,51 @@ +from typing import Optional, Union + import numpy as np +import numpy.ma as ma from numpy import ndarray +from numpy.typing import ArrayLike + +from foapy.core._axis_transform import _apply_to_axis_lanes +from foapy.core._factorize import _normalize_sequence_axis -def intervals_distribution(tuple_result) -> ndarray: +def intervals_distribution( + tuple_result: ArrayLike, *, axis: Optional[int] = None +) -> Union[ndarray, ma.MaskedArray]: """ - Compute the frequency distribution of interval values. + Compute frequency distributions of interval-tuple lanes. - Given the output of :func:`intervals_tuple` (or any 1-D array of strictly - positive integers), counts how many times each interval value occurs. - The result is a 1-D array of length ``max(tuple_result)`` where - ``result[i]`` is the count of interval value ``i + 1``. + For one-dimensional input, count how many times each positive interval + value occurs. For multidimensional input, ``axis`` selects independent + one-dimensional tuples in the style of :func:`numpy.apply_along_axis`. + Masked positions, including padding from axis-aware + :func:`foapy.intervals_tuple`, are excluded from counts. Parameters ---------- - tuple_result : array_like - 1-D array of strictly positive integers, typically produced by - :func:`intervals_tuple`. + tuple_result : array_like or numpy.ma.MaskedArray + One interval tuple, or a multidimensional collection of tuples. + Unmasked values must be strictly positive integers. + axis : int, optional + Axis containing each independent tuple. If omitted, ``tuple_result`` + must be one-dimensional. Negative axes follow NumPy conventions. Returns ------- - ndarray - 1-D integer array of length ``max(tuple_result)`` (or empty for - empty input). ``result[i]`` equals the number of elements in - *tuple_result* with value ``i + 1``. + numpy.ndarray or numpy.ma.MaskedArray + One-dimensional input returns a plain ``numpy.intp`` array of length + ``max(tuple_result)``. Multidimensional input returns a masked + ``numpy.intp`` array whose selected axis is the longest lane + distribution. Shorter distributions have trailing masked bins; + zero-frequency bins within a lane's distribution remain unmasked. + + Raises + ------ + Not1DArrayException + When input is scalar, or is multidimensional without an explicit + axis. + numpy.exceptions.AxisError + When an explicit axis is out of range. Examples -------- @@ -37,10 +60,68 @@ def intervals_distribution(tuple_result) -> ndarray: print(intervals_distribution(np.array([]))) # [] ``` + + Process independent tuple rows. The zero in the first distribution is a + real count, not padding: + + ``` py linenums="1" + import numpy as np + import foapy + + tuples = np.array([[1, 1, 3, 1], [1, 2, 1, 3]]) + result = foapy.intervals_distribution(tuples, axis=1) + print(result) + # [[3 0 1] + # [2 1 1]] + ``` """ - ar = np.asanyarray(tuple_result, dtype=np.intp) + if ( + axis is None + and type(tuple_result) is ndarray + and tuple_result.ndim == 1 + and tuple_result.dtype == np.intp + ): + if tuple_result.size == 0: + return np.array([], dtype=np.intp) + return np.bincount(tuple_result - 1).astype(np.intp) + + data = np.asanyarray(tuple_result) + + if data.ndim == 1: + if axis is not None: + _normalize_sequence_axis(data, axis) + if ma.isMaskedArray(data): + data = ma.asarray(data, dtype=np.intp).compressed() + elif data.dtype != np.intp: + data = np.asanyarray(data, dtype=np.intp) + return _intervals_distribution_1d(data) + + return _apply_to_axis_lanes(data, axis, _intervals_distribution_lanes) - if ar.size == 0: + +def _intervals_distribution_1d(tuple_result: ndarray) -> ndarray: + """Compute an interval distribution for one one-dimensional tuple.""" + if tuple_result.size == 0: return np.array([], dtype=np.intp) - return np.bincount(ar - 1).astype(np.intp) + return np.bincount(tuple_result - 1).astype(np.intp) + + +def _intervals_distribution_lanes(tuple_results: ndarray) -> ma.MaskedArray: + """Compute distributions for a complete prepared tuple-lane batch.""" + values = np.asarray(ma.getdata(tuple_results), dtype=np.intp) + valid = ~ma.getmaskarray(tuple_results) + if np.any(values[valid] <= 0): + raise ValueError("interval values must be positive") + + result_length = int(np.max(values, where=valid, initial=0)) + counts = np.zeros((values.shape[0], result_length), dtype=np.intp) + lane_indices = np.broadcast_to( + np.arange(values.shape[0], dtype=np.intp)[:, None], values.shape + ) + np.add.at(counts, (lane_indices[valid], values[valid] - 1), 1) + lane_maximums = np.max(values, axis=1, where=valid, initial=0) + structural_mask = ( + np.arange(result_length, dtype=np.intp)[None, :] >= lane_maximums[:, None] + ) + return ma.masked_array(counts, mask=structural_mask) diff --git a/src/foapy/core/_intervals_tuple.py b/src/foapy/core/_intervals_tuple.py index d2d0caa8..0285fc3e 100644 --- a/src/foapy/core/_intervals_tuple.py +++ b/src/foapy/core/_intervals_tuple.py @@ -1,16 +1,31 @@ +from typing import Optional, Union + import numpy as np +import numpy.ma as ma from numpy import ndarray +from numpy.typing import ArrayLike +from foapy.core._axis_transform import _apply_to_axis_lanes, _pack_axis_lane_values from foapy.core._binding import binding as binding_cls +from foapy.core._factorize import _normalize_sequence_axis +from foapy.core._intervals_chain_validation import is_valid_intervals_chain from foapy.core._tuple_mode import tuple_mode as tuple_mode_cls -def intervals_tuple(chain, binding: int, tuple_mode: int) -> ndarray: +def intervals_tuple( + chain: ArrayLike, + binding: int, + tuple_mode: int, + *, + axis: Optional[int] = None, +) -> Union[ndarray, ma.MaskedArray]: """ - Apply a boundary handling strategy to a plain 1-D intervals chain. + Apply a boundary strategy to interval-chain lanes. - Takes a chain produced by ``intervals_chain`` and transforms it according - to the requested ``tuple_mode``: + A one-dimensional input is transformed directly. For multidimensional + input, ``axis`` selects independent one-dimensional chains in the style + of :func:`numpy.apply_along_axis`; every combination of coordinates on + the other dimensions is processed separately. - ``tuple_mode.normal``: return the chain unchanged. - ``tuple_mode.lossy``: remove boundary (first-/last-occurrence) intervals, @@ -23,8 +38,9 @@ def intervals_tuple(chain, binding: int, tuple_mode: int) -> ndarray: Parameters ---------- chain : array_like - A 1-D intervals chain produced by ``intervals_chain``. - Must be a 1-D array of positive integers. + One intervals chain, or a multidimensional collection of chains. + Each selected-axis lane must be a one-dimensional chain produced by + :func:`foapy.intervals_chain`. binding : int ``binding.start`` (1) — chain was produced left-to-right. ``binding.end`` (2) — chain was produced right-to-left. @@ -39,19 +55,30 @@ def intervals_tuple(chain, binding: int, tuple_mode: int) -> ndarray: ``tuple_mode.redundant`` = 3 – include both boundary intervals for every element. + axis : int, optional + Axis containing each independent chain. If omitted, ``chain`` must be + one-dimensional. Negative axes follow NumPy conventions. Returns ------- - ndarray - 1-D integer array of intervals. Length equals ``n`` for *normal*, - ``n - k`` for *lossy*, and ``n + k`` for *redundant*, where ``n`` is - the chain length and ``k`` is the number of unique elements inferred - from the chain. + numpy.ndarray or numpy.ma.MaskedArray + One-dimensional input returns a plain ``numpy.intp`` array. Its + length is ``n`` for *normal*, ``n - k`` for *lossy*, and ``n + k`` + for *redundant*, where ``k`` is the inferred number of elements. + Multidimensional input returns a masked ``numpy.intp`` array. The + selected axis is replaced by the longest lane result; shorter lane + results are packed from index zero and trailing positions are masked. Raises ------ + Not1DArrayException + When input is scalar, or is multidimensional without an explicit + axis. + numpy.exceptions.AxisError + When an explicit axis is out of range. ValueError - When ``tuple_mode`` is not a recognised value. + When ``binding`` or ``tuple_mode`` is invalid, or internal interval- + chain validation fails. Examples -------- @@ -73,42 +100,42 @@ def intervals_tuple(chain, binding: int, tuple_mode: int) -> ndarray: print(intervals_tuple(chain, foapy.binding.start, foapy.tuple_mode.redundant)) # [1 2 2 4 2 4 2 1] ``` - """ - - def normal(ar): - return ar.copy() - def lossy(ar): - # Infer binding direction to choose correct boundary detection formula. - if binding == binding_cls.end: - ar = ar[::-1] + Process independent row chains and preserve unequal lossy lengths with + trailing masks: - positions = np.arange(ar.size, dtype=np.intp) - - # First entrance interval at position i iff ar[i] > i - first = ar > positions - - return ar[~first] + ``` py linenums="1" + import numpy as np + import foapy - def redundant(ar): - # If the chain was created using binding.end, reverse it for correct handling. - if binding == binding_cls.end: - ar = ar[::-1] + chains = np.array([[1, 1, 1, 1], [1, 2, 3, 4]]) + result = foapy.intervals_tuple( + chains, + foapy.binding.start, + foapy.tuple_mode.lossy, + axis=1, + ) + print(result) + # [[1 1 1] + # [-- -- --]] + ``` - n = ar.size - positions = np.arange(n, dtype=np.intp) + Three-dimensional lane processing follows the same rule: - # For each position, compute where its "previous occurrence" is. - prev_pos = positions - ar - # Build a mask to detect "last occurrences" - # (not referred to as previous by any other element). - last_mask = np.ones_like(positions, dtype=bool) - last_mask[prev_pos[prev_pos >= 0]] = False - # Trailing intervals are n - position for each detected "last occurrence". - trailing = n - positions[last_mask] + ``` py linenums="1" + batch = np.stack((chains.T, chains.T)) # shape (2, 4, 2) + result = foapy.intervals_tuple( + batch, + foapy.binding.start, + foapy.tuple_mode.lossy, + axis=1, + ) + print(result.shape) # (2, 3, 2) + ``` - # Concatenate chain with its trailing intervals. - return np.concatenate((ar, trailing)) + In general, for shape ``(A, B, C)``, selecting axes 0, 1, or 2 produces + ``(L, B, C)``, ``(A, L, C)``, or ``(A, B, L)`` respectively. + """ if binding not in {binding_cls.start, binding_cls.end}: raise ValueError( @@ -127,16 +154,97 @@ def redundant(ar): } ) - ar = np.asanyarray(chain, dtype=np.intp) + data = chain if isinstance(chain, np.ndarray) else np.asanyarray(chain) + + if data.ndim == 1: + if axis is not None: + _normalize_sequence_axis(data, axis) + prepared = data if data.dtype == np.intp else np.asanyarray(data, dtype=np.intp) + if not is_valid_intervals_chain(prepared): + raise ValueError({"message": "Invalid intervals chain."}) + return _intervals_tuple_1d(prepared, binding, tuple_mode) + + prepared = data if data.dtype == np.intp else np.asanyarray(data, dtype=np.intp) + + return _apply_to_axis_lanes( + prepared, + axis, + lambda lanes: _validated_intervals_tuple_lanes(lanes, binding, tuple_mode), + ) + + +def _validated_intervals_tuple_lanes( + lanes: ndarray, binding: int, tuple_mode: int +) -> ma.MaskedArray: + """Validate and transform a prepared interval-chain lane batch.""" + if not is_valid_intervals_chain(lanes, axis=1): + raise ValueError({"message": "Invalid intervals chain."}) + return _intervals_tuple_lanes(lanes, binding, tuple_mode) + + +def _intervals_tuple_lanes( + lanes: ndarray, binding: int, tuple_mode: int +) -> ma.MaskedArray: + """Apply one tuple mode to a complete prepared lane batch.""" + if tuple_mode == tuple_mode_cls.normal: + return ma.masked_array(lanes.copy(), mask=np.zeros(lanes.shape, dtype=bool)) + + work = lanes[:, ::-1] if binding == binding_cls.end else lanes + positions = np.broadcast_to( + np.arange(work.shape[1], dtype=np.intp)[None, :], work.shape + ) + + if tuple_mode == tuple_mode_cls.lossy: + return _pack_axis_lane_values(work, work <= positions) + + prev_pos = positions - work + last = np.ones(work.shape, dtype=bool) + valid_prev = prev_pos >= 0 + lane_indices = np.broadcast_to( + np.arange(work.shape[0], dtype=np.intp)[:, None], work.shape + ) + last[lane_indices[valid_prev], prev_pos[valid_prev]] = False + trailing = work.shape[1] - np.arange(work.shape[1], dtype=np.intp)[None, :] + prefix_width = work.shape[1] * min(work.shape[0], 1) + return _pack_axis_lane_values( + trailing, + last, + prefix=work[:, :prefix_width], + ) + + +def _intervals_tuple_1d(ar: ndarray, binding: int, tuple_mode: int) -> ndarray: + """Apply a tuple mode to one already validated interval chain.""" if ar.size == 0: return np.array([], dtype=np.intp) if tuple_mode == tuple_mode_cls.normal: - return normal(ar) + return ar.copy() if tuple_mode == tuple_mode_cls.lossy: - return lossy(ar) + return _lossy(ar, binding) + + return _redundant(ar, binding) + + +def _lossy(ar: ndarray, binding: int) -> ndarray: + if binding == binding_cls.end: + ar = ar[::-1] + + positions = np.arange(ar.size, dtype=np.intp) + first = ar > positions + return ar[~first] + + +def _redundant(ar: ndarray, binding: int) -> ndarray: + if binding == binding_cls.end: + ar = ar[::-1] - if tuple_mode == tuple_mode_cls.redundant: - return redundant(ar) + n = ar.size + positions = np.arange(n, dtype=np.intp) + prev_pos = positions - ar + last_mask = np.ones_like(positions, dtype=bool) + last_mask[prev_pos[prev_pos >= 0]] = False + trailing = n - positions[last_mask] + return np.concatenate((ar, trailing)) diff --git a/src/foapy/core/_order.py b/src/foapy/core/_order.py index 5b642094..7e3e1181 100644 --- a/src/foapy/core/_order.py +++ b/src/foapy/core/_order.py @@ -1,13 +1,21 @@ +from typing import Optional, Tuple, Union + import numpy as np from numpy import ndarray +from numpy.typing import ArrayLike -from foapy.exceptions import Not1DArrayException +from foapy.core._factorize import _normalize_sequence_axis, _stable_factorize -def order(X, return_alphabet: bool = False) -> ndarray: +def order( + X: ArrayLike, + return_alphabet: bool = False, + *, + axis: Optional[int] = None, +) -> Union[ndarray, Tuple[ndarray, ndarray]]: """ - Decompose an array into an order and an alphabet. + Decompose a sequence into its one-dimensional order and alphabet. Alphabet is a list of all unique values from the input array in order of their first appearance. Order is an array of indices that maps each element in the input array to its position @@ -22,24 +30,32 @@ def order(X, return_alphabet: bool = False) -> ndarray: Parameters ---------- - X : np.array_like - Array to decompose into an order and an alphabet. Must be a 1-dimensional array. + X : array_like + Sequence to factorize. With an explicit ``axis``, each complete + orthogonal slice indexed along that axis is one sequence element. return_alphabet : bool, optional If True also return array's alphabet + axis : int, optional + Sequence axis. If omitted, ``X`` must be 1-dimensional. Negative + axes follow NumPy conventions. Returns ------- order : ndarray - Order of X + One-dimensional order of length ``X.shape[axis]`` for an explicit + axis, or ``len(X)`` for the legacy one-dimensional call. alphabet : ndarray - Alphabet of X. Only provided if `return_alphabet` is True. + Alphabet of X. Only provided if ``return_alphabet`` is True. For an + explicit axis it retains the input rank and axis placement. Raises ------- Not1DArrayException - When X parameter is not d1 array + When ``X`` is scalar, or is multidimensional without an explicit axis. + numpy.exceptions.AxisError + When an explicit axis is out of range. Examples -------- @@ -77,42 +93,101 @@ def order(X, return_alphabet: bool = False) -> ndarray: # [] ``` - Getting an order of an array with more than 1 dimension is not allowed + Treat complete rows as elements and reconstruct the source: ``` py linenums="1" + import numpy as np import foapy - source = [[[1], [3]], [[6], [9]], [[6], [3]]] - order = foapy.order(source) - # Not1DArrayException: {'message': 'Incorrect array form. Expected d1 array, exists 3'} + source = np.array([[2, 1], [3, 4], [2, 1]]) + result, alphabet = foapy.order(source, True, axis=0) + print(result) + # [0 1 0] + restored = np.take(alphabet, result, axis=0) + print(np.array_equal(restored, source)) + # True + ``` + + The same rule applies to an interior axis of a 3-dimensional input: + + ``` py linenums="1" + import numpy as np + import foapy + + source = np.array( + [ + [[1, 2], [3, 4], [1, 2]], + [[5, 6], [7, 8], [5, 6]], + ] + ) + result, alphabet = foapy.order(source, True, axis=-2) + print(result) + # [0 1 0] + restored = np.take(alphabet, result, axis=-2) + print(np.array_equal(restored, source)) + # True ``` """ # noqa: E501 data = np.asanyarray(X) - if data.ndim > 1: # Checking for d1 array - raise Not1DArrayException( - {"message": f"Incorrect array form. Expected d1 array, exists {data.ndim}"} - ) - perm = data.argsort(kind="mergesort") + if data.ndim != 1: + result, alphabet = _stable_factorize(data, axis=axis) - unique_mask = np.empty(data.shape, dtype=bool) - unique_mask[:1] = True - unique_mask[1:] = data[perm[1:]] != data[perm[:-1]] + if return_alphabet: + return result, alphabet + return result - result_mask = np.zeros_like(unique_mask) - result_mask[:1] = True - result_mask[perm[unique_mask]] = True + if axis is not None: + _normalize_sequence_axis(data, axis) - power = np.count_nonzero(unique_mask) + length = data.shape[0] + if length == 0: + empty = np.array([], dtype=np.intp) + return (empty, data.copy()) if return_alphabet else empty - inverse_perm = np.empty(data.shape, dtype=np.intp) - inverse_perm[perm] = np.arange(data.shape[0]) + # An unstable sort is enough: it only has to group equal values together. + # The reverse scatter below recovers first-occurrence positions whatever + # order the sort left ties in, so we avoid paying for a stable sort. + perm = data.argsort(kind="quicksort") - result = np.cumsum(unique_mask) - 1 - inverse_alphabet_perm = np.empty(power, dtype=np.intp) - inverse_alphabet_perm[result[inverse_perm][result_mask]] = np.arange(power) + sorted_data = data[perm] + unique_mask = np.empty(length, dtype=bool) + unique_mask[:1] = True + unique_mask[1:] = sorted_data[1:] != sorted_data[:-1] + del sorted_data + + power = np.count_nonzero(unique_mask) - result = inverse_alphabet_perm[result][inverse_perm] + # A single-valued sequence needs none of the remapping below: every + # element is the same alphabet entry, so the order is all zeros. + if power == 1: + result = np.zeros(length, dtype=np.intp) + if return_alphabet: + return result, data[:1].copy() + return result + + # Rank of each element within the value-sorted alphabet. + sorted_rank = np.cumsum(unique_mask, dtype=np.intp) + sorted_rank -= 1 + sorted_inverse = np.empty(length, dtype=np.intp) + sorted_inverse[perm] = sorted_rank + # Released before the scatter below allocates, keeping the peak down. + del perm, sorted_rank + + # Scatter positions in reverse so the last write for each rank is the + # smallest index, i.e. that value's first occurrence. + first_indices = np.empty(power, dtype=np.intp) + first_indices[sorted_inverse[::-1]] = np.arange(length - 1, -1, -1) + + result_mask = np.zeros(length, dtype=bool) + result_mask[first_indices] = True + + # Reading sorted ranks at first-occurrence positions visits them in + # first-appearance order, so this needs a scatter rather than a sort. + sorted_to_stable = np.empty(power, dtype=np.intp) + sorted_to_stable[sorted_inverse[result_mask]] = np.arange(power) + + result = sorted_to_stable[sorted_inverse] if return_alphabet: return result, data[result_mask] diff --git a/src/foapy/partials/_alphabet.py b/src/foapy/partials/_alphabet.py index ddc3b422..b946204d 100644 --- a/src/foapy/partials/_alphabet.py +++ b/src/foapy/partials/_alphabet.py @@ -1,36 +1,46 @@ +from typing import Optional + import numpy as np import numpy.ma as ma from numpy.typing import ArrayLike from foapy.core._alphabet import alphabet as core_alphabet -from foapy.exceptions import Not1DArrayException +from foapy.core._factorize import _normalize_sequence_axis +from foapy.partials._factorize import _stable_partial_factorize -def alphabet(X: ArrayLike) -> np.ndarray: +def alphabet(X: ArrayLike, *, axis: Optional[int] = None) -> np.ndarray: """ - Extract the alphabet of a partial sequence. + Extract the alphabet of a dense or partial sequence. - Unique unmasked values are returned in the order of their first - unmasked appearance. Masked positions are ignored, so a value that is - masked at its first occurrence is introduced when it is encountered - later in an unmasked position. + Unique present elements are returned in first-appearance order. With an + explicit ``axis``, each complete orthogonal slice is one element. A slice + must be wholly present or wholly masked; wholly masked slices are gaps and + are excluded. Parameters ---------- X : array_like or numpy.ma.MaskedArray - 1-D sequence (plain or masked). Masked positions are excluded. + Sequence (plain or masked). If ``axis`` is omitted it must be 1-D. + axis : int, optional + Sequence axis. Negative axes follow NumPy conventions. The returned + alphabet retains this axis in the same position. Returns ------- - alphabet : numpy.ndarray, shape (p,) - Unique non-masked values in first-appearance order. - p = number of unique non-masked values. Empty array when all positions - are masked or input is empty. + alphabet : numpy.ndarray + Plain array of unique non-gap elements in first-appearance order. For + an explicit axis, the input rank and all orthogonal dimensions are + preserved while the selected axis has alphabet length. Raises ------ Not1DArrayException - When X has more than one dimension. + When ``X`` is scalar, or is multidimensional without an explicit axis. + ValueError + When a slice along an explicit axis is only partially masked. + numpy.exceptions.AxisError + When an explicit axis is out of range. Examples -------- @@ -40,68 +50,53 @@ def alphabet(X: ArrayLike) -> np.ndarray: import foapy source = ['a', 'c', 'c', 'e', 'd', 'a'] - alphabet = foapy.partials.alphabet(source) - print(alphabet) - # ['a', 'c', 'e', 'd'] + result = foapy.partials.alphabet(source) + print(result) + # ['a' 'c' 'e' 'd'] ``` - Masked positions are excluded from the alphabet: + Masked scalar positions are excluded: ``` py linenums="1" import numpy.ma as ma import foapy source = ma.masked_array(['a', 'x', 'b', 'a'], mask=[0, 1, 0, 0]) - alphabet = foapy.partials.alphabet(source) - print(alphabet) - # ['a', 'b'] - ``` - - If the first occurrence of a value is masked, a later unmasked - occurrence determines its position in the alphabet: - - ``` py linenums="1" - import numpy.ma as ma - import foapy - - source = ma.masked_array(['a', 'a', 'b', 'a'], mask=[1, 0, 0, 0]) - alphabet = foapy.partials.alphabet(source) - print(alphabet) - # ['a', 'b'] + result = foapy.partials.alphabet(source) + print(result) + # ['a' 'b'] ``` - An empty or fully masked sequence has an empty alphabet: + Fully masked rows are excluded when rows are selected as elements: ``` py linenums="1" import numpy.ma as ma import foapy - source = ma.masked_array(['a', 'b'], mask=[1, 1]) - alphabet = foapy.partials.alphabet(source) - print(alphabet) - # [] + source = ma.masked_array( + [[1, 2], [9, 9], [3, 4], [1, 2]], + mask=[[0, 0], [1, 1], [0, 0], [0, 0]], + ) + result = foapy.partials.alphabet(source, axis=0) + print(result) + # [[1 2] + # [3 4]] ``` - Inputs with more than one dimension are rejected: - - ``` py linenums="1" - import foapy - - source = [[1, 2], [3, 4]] - alphabet = foapy.partials.alphabet(source) - # Not1DArrayException: - # {'message': 'Incorrect array form. Expected d1 array, exists 2'} - ``` + A plain or fully unmasked multidimensional input produces the same + alphabet as :func:`foapy.core.alphabet` for the same axis. """ - ar = ma.asarray(X) + data = ma.asarray(X) + + if data.ndim != 1: + _, result = _stable_partial_factorize(data, axis=axis) + return result - if ar.ndim > 1: - raise Not1DArrayException( - {"message": f"Incorrect array form. Expected d1 array, exists {ar.ndim}"} - ) + if axis is not None: + _normalize_sequence_axis(data, axis) - compressed = ar.compressed() + compressed = data.compressed() if len(compressed) == 0: - return np.array([], dtype=ar.dtype) + return np.array([], dtype=data.dtype) return core_alphabet(compressed) diff --git a/src/foapy/partials/_factorize.py b/src/foapy/partials/_factorize.py new file mode 100644 index 00000000..a6bd48da --- /dev/null +++ b/src/foapy/partials/_factorize.py @@ -0,0 +1,48 @@ +from typing import Optional, Tuple + +import numpy as np +import numpy.ma as ma +from numpy.typing import ArrayLike + +from foapy.core._factorize import _normalize_sequence_axis, _stable_factorize + + +def _stable_partial_factorize( + X: ArrayLike, axis: Optional[int] = None +) -> Tuple[ma.MaskedArray, np.ndarray]: + """Factorize present slices and preserve whole-slice gaps in the order.""" + data = ma.asarray(X) + normalized_axis = _normalize_sequence_axis(data, axis) + + moved_mask = np.moveaxis(ma.getmaskarray(data), normalized_axis, 0) + sequence_length = moved_mask.shape[0] + field_count = int(np.prod(moved_mask.shape[1:], dtype=np.intp)) + + if field_count == 0: + gap_mask = np.zeros(sequence_length, dtype=bool) + else: + slice_masks = moved_mask.reshape(sequence_length, field_count) + any_masked = slice_masks.any(axis=1) + all_masked = slice_masks.all(axis=1) + + if np.any(any_masked != all_masked): + raise ValueError( + { + "message": ( + "Each slice along axis must be wholly masked or " + "wholly unmasked." + ) + } + ) + + gap_mask = all_masked + + present = ~gap_mask + present_data = np.compress(present, data.data, axis=normalized_axis) + present_order, alphabet = _stable_factorize(present_data, axis=normalized_axis) + + result_data = np.zeros(sequence_length, dtype=np.intp) + result_data[present] = present_order + result = ma.masked_array(result_data, mask=gap_mask) + + return result, alphabet diff --git a/src/foapy/partials/_intervals_chain.py b/src/foapy/partials/_intervals_chain.py index 4cedd485..6487c03e 100644 --- a/src/foapy/partials/_intervals_chain.py +++ b/src/foapy/partials/_intervals_chain.py @@ -1,12 +1,23 @@ +from typing import Optional + import numpy as np import numpy.ma as ma +from numpy.typing import ArrayLike from foapy.core._binding import binding as binding_cls from foapy.core._chain_mode import chain_mode as chain_mode_cls -from foapy.exceptions import Not1DArrayException - - -def intervals_chain(X, binding: int, chain_mode: int) -> ma.MaskedArray: +from foapy.core._factorize import _normalize_sequence_axis +from foapy.core._intervals_chain import _intervals_chain_1d as _core_intervals_chain_1d +from foapy.partials._order import order as partial_order + + +def intervals_chain( + X: ArrayLike, + binding: int, + chain_mode: int, + *, + axis: Optional[int] = None, +) -> ma.MaskedArray: """ Compute the partial intervals chain from a sequence with gaps. @@ -18,8 +29,10 @@ def intervals_chain(X, binding: int, chain_mode: int) -> ma.MaskedArray: Parameters ---------- X : array_like or numpy.ma.MaskedArray - 1-D raw sequence (plain or masked). Pass the original sequence, not - the order output. Masked positions are treated as gaps. + Raw sequence (plain or masked). Pass the original sequence, not the + order output. With an explicit ``axis``, each complete orthogonal + slice is one element. A slice must be wholly present or wholly masked; + wholly masked slices are positional gaps. binding : int ``binding.start`` (1) — intervals extracted left-to-right. ``binding.end`` (2) — intervals extracted right-to-left. @@ -27,20 +40,26 @@ def intervals_chain(X, binding: int, chain_mode: int) -> ma.MaskedArray: ``chain_mode.boundary`` (1) — finite sequence; boundary intervals are distances from sequence edges to first/last occurrence. ``chain_mode.cycle`` (2) — cyclic; wrap-around distance used. + axis : int, optional + Sequence axis. If omitted, ``X`` must be one-dimensional. Negative + axes follow NumPy conventions. Returns ------- numpy.ma.MaskedArray, shape (n,), dtype numpy.intp - Masked 1-D array of the same length as X. Non-masked positions hold - the interval distance (≥1). Masked positions are identical to the - input mask. + Masked one-dimensional array where ``n`` is the selected-axis length. + Present positions hold interval distances (≥1), and wholly masked + slices remain masked. Gap positions count toward every distance. Raises ------ Not1DArrayException - When X is not a 1-dimensional array. + When ``X`` is scalar, or is multidimensional without an explicit axis. + numpy.exceptions.AxisError + When an explicit axis is out of range. ValueError - When ``binding`` or ``chain_mode`` is invalid. + When ``binding`` or ``chain_mode`` is invalid, or a selected-axis + slice is only partially masked. Examples -------- @@ -75,6 +94,26 @@ def intervals_chain(X, binding: int, chain_mode: int) -> ma.MaskedArray: With no masked positions, the non-masked values match :func:`foapy.intervals_chain` for the same binding and chain mode. + + A wholly masked row is a gap that remains in the selected-axis coordinate + system and therefore counts toward interval distances: + + ``` py linenums="1" + import numpy.ma as ma + import foapy + + source = ma.masked_array( + [[1, 2], [9, 9], [3, 4], [1, 2]], + mask=[[0, 0], [1, 1], [0, 0], [0, 0]], + ) + chain = foapy.partials.intervals_chain( + source, + foapy.binding.start, + foapy.chain_mode.boundary, + axis=0, + ) + print(chain) # [1 -- 3 3] + ``` """ if binding not in {binding_cls.start, binding_cls.end}: raise ValueError( @@ -91,12 +130,31 @@ def intervals_chain(X, binding: int, chain_mode: int) -> ma.MaskedArray: } ) - ar = ma.asarray(X) + if not ma.isMaskedArray(X): + dense_data = np.asanyarray(X) - if ar.ndim != 1: - raise Not1DArrayException( - {"message": f"Incorrect array form. Expected d1 array, exists {ar.ndim}"} - ) + if dense_data.ndim == 1: + if axis is not None: + _normalize_sequence_axis(dense_data, axis) + + return ma.asarray(_core_intervals_chain_1d(dense_data, binding, chain_mode)) + + data = ma.asarray(X) + + if data.ndim != 1: + sequence_order = partial_order(data, axis=axis) + return _intervals_chain_1d(sequence_order, binding, chain_mode) + + if axis is not None: + _normalize_sequence_axis(data, axis) + + return _intervals_chain_1d(data, binding, chain_mode) + + +def _intervals_chain_1d( + ar: ma.MaskedArray, binding: int, chain_mode: int +) -> ma.MaskedArray: + """Compute an interval chain for an already validated 1-D partial sequence.""" n = len(ar) full_mask = ma.getmaskarray(ar) diff --git a/src/foapy/partials/_intervals_tuple.py b/src/foapy/partials/_intervals_tuple.py index 3d41f112..1937cd64 100644 --- a/src/foapy/partials/_intervals_tuple.py +++ b/src/foapy/partials/_intervals_tuple.py @@ -1,26 +1,37 @@ +from typing import Optional, Union + import numpy as np import numpy.ma as ma +from numpy import ndarray +from numpy.typing import ArrayLike +from foapy.core._axis_transform import _apply_to_axis_lanes, _pack_axis_lane_values from foapy.core._binding import binding as binding_cls +from foapy.core._factorize import _normalize_sequence_axis from foapy.core._tuple_mode import tuple_mode as tuple_mode_cls -def intervals_tuple(chain, binding: int, tuple_mode: int) -> np.ndarray: +def intervals_tuple( + chain: ArrayLike, + binding: int, + tuple_mode: int, + *, + axis: Optional[int] = None, +) -> Union[ndarray, ma.MaskedArray]: """ - Apply a boundary handling strategy to a partial intervals chain, dropping - gaps and returning the final flat tuple of interval values. + Apply a boundary strategy to one or more partial interval-chain lanes. - Unlike ``partials.intervals_chain`` (position-preserving, aligned to the - source sequence), ``intervals_tuple`` returns a plain array: gap (masked) - positions carry no positional meaning once a boundary strategy has been - applied, so they are excluded from the result rather than masked within - it — matching ``foapy.core.intervals_tuple``'s return type. + Within each lane, masked positions remain source-coordinate gaps while the + tuple strategy is calculated, then are excluded from the lane result. + With multidimensional input, ``axis`` selects independent one-dimensional + lanes in the style of :func:`numpy.apply_along_axis`. Unequal result + lengths are packed from index zero and trailing positions are masked. Parameters ---------- chain : array_like or numpy.ma.MaskedArray - 1-D intervals chain produced by ``partials.intervals_chain``. - Plain arrays are auto-wrapped (treated as fully unmasked). + One partial intervals chain, or a multidimensional collection of + chains. Plain arrays are treated as fully unmasked. binding : int Must match the binding used to produce the chain. ``binding.start`` (1) or ``binding.end`` (2). @@ -32,19 +43,29 @@ def intervals_tuple(chain, binding: int, tuple_mode: int) -> np.ndarray: ``tuple_mode.redundant`` (3) — append one trailing complementary boundary interval per inferred unique symbol, measured against the true source domain length (gaps included). + axis : int, optional + Axis containing each independent interval chain. If omitted, ``chain`` + must be one-dimensional. Negative axes follow NumPy conventions. Returns ------- - numpy.ndarray - 1-D array, dtype ``numpy.intp``, with all gap positions excluded. - For ``binding.end``, element order follows - ``foapy.core.intervals_tuple``'s own (reversed-frame) convention - rather than the source sequence's left-to-right order. + numpy.ndarray or numpy.ma.MaskedArray + One-dimensional input returns a plain ``numpy.intp`` array with gaps + excluded. Multidimensional input always returns a masked + ``numpy.intp`` array whose selected axis has the longest lane result; + masks in this output are structural trailing padding, not source + gaps. For ``binding.end``, each lane follows + :func:`foapy.core.intervals_tuple`'s reversed processing frame. Raises ------ ValueError When ``binding`` or ``tuple_mode`` is invalid. + Not1DArrayException + When input is scalar, or is multidimensional without an explicit + axis. + numpy.exceptions.AxisError + When an explicit axis is out of range. Examples -------- @@ -66,16 +87,49 @@ def intervals_tuple(chain, binding: int, tuple_mode: int) -> np.ndarray: # [2 3 2 6 4 3 1] ``` - Dense input (no gaps) matches :func:`foapy.core.intervals_tuple` exactly, - including element order for ``binding.end``: + Process two partial chains independently. The first lossy tuple has one + value, so its second packed position is structurally masked: ``` py linenums="1" + import numpy.ma as ma import foapy - from foapy.partials import intervals_tuple - chain = [1, 2, 2, 4, 2] - print(intervals_tuple(chain, foapy.binding.end, foapy.tuple_mode.lossy)) - # [2 2 1] + chains = ma.masked_array( + [[1, 0, 3, 3, 0, 6], [0, 2, 1, 4, 2, 0]], + mask=[[0, 1, 0, 0, 1, 0], [1, 0, 0, 0, 0, 1]], + ) + tuples = foapy.partials.intervals_tuple( + chains, + foapy.binding.start, + foapy.tuple_mode.lossy, + axis=1, + ) + print(tuples) + # [[3 --] + # [1 2]] + + print(foapy.intervals_distribution(tuples, axis=1)) + # [[0 0 1] + # [1 1 --]] + ``` + + The selected result dimension replaces the input axis. For shape + ``(A, B, C)``, axes 0, 1, and 2 therefore produce ``(L, B, C)``, + ``(A, L, C)``, and ``(A, B, L)`` respectively, where ``L`` is the longest + lane result: + + ``` py linenums="1" + import numpy.ma as ma + import foapy + + batch = ma.stack([chains.T, chains.T]) # shape (2, 6, 2) + result = foapy.partials.intervals_tuple( + batch, + foapy.binding.start, + foapy.tuple_mode.lossy, + axis=1, + ) + print(result.shape) # (2, 2, 2) ``` """ if binding not in {binding_cls.start, binding_cls.end}: @@ -98,7 +152,22 @@ def intervals_tuple(chain, binding: int, tuple_mode: int) -> np.ndarray: } ) - ar = ma.asarray(chain) + data = ma.asarray(chain) + + if data.ndim == 1: + if axis is not None: + _normalize_sequence_axis(data, axis) + return _intervals_tuple_1d(data, binding, tuple_mode) + + return _apply_to_axis_lanes( + data, + axis, + lambda lanes: _intervals_tuple_lanes(lanes, binding, tuple_mode), + ) + + +def _intervals_tuple_1d(ar: ma.MaskedArray, binding: int, tuple_mode: int) -> ndarray: + """Transform one partial interval-chain lane while retaining gap positions.""" chain_mask = ma.getmaskarray(ar) compressed = ar.compressed().astype(np.intp) @@ -117,6 +186,44 @@ def intervals_tuple(chain, binding: int, tuple_mode: int) -> np.ndarray: return _redundant(compressed, non_masked_idx, binding, n_full) +def _intervals_tuple_lanes( + lanes: ma.MaskedArray, binding: int, tuple_mode: int +) -> ma.MaskedArray: + """Apply one partial tuple mode to a complete masked lane batch.""" + values = np.asarray(ma.getdata(lanes), dtype=np.intp) + masked = ma.getmaskarray(lanes) + + if tuple_mode == tuple_mode_cls.normal: + return _pack_axis_lane_values(values, ~masked) + + if binding == binding_cls.end: + work = values[:, ::-1] + work_mask = masked[:, ::-1] + else: + work = values + work_mask = masked + + valid = ~work_mask + positions = np.broadcast_to( + np.arange(work.shape[1], dtype=np.intp)[None, :], work.shape + ) + + if tuple_mode == tuple_mode_cls.lossy: + return _pack_axis_lane_values(work, valid & (work <= positions)) + + prev_pos = positions - work + last = valid.copy() + valid_prev = valid & (prev_pos >= 0) + lane_indices = np.broadcast_to( + np.arange(work.shape[0], dtype=np.intp)[:, None], work.shape + ) + last[lane_indices[valid_prev], prev_pos[valid_prev]] = False + trailing = work.shape[1] - positions + combined_values = np.concatenate((work, trailing), axis=1) + combined_selected = np.concatenate((valid, last), axis=1) + return _pack_axis_lane_values(combined_values, combined_selected) + + def _lossy(compressed, non_masked_idx, binding, n_full): # A boundary interval's value always exceeds its own real source # position (chain_mode.boundary sets it to position + 1; an interior diff --git a/src/foapy/partials/_order.py b/src/foapy/partials/_order.py index c7ccac06..e1a600c1 100644 --- a/src/foapy/partials/_order.py +++ b/src/foapy/partials/_order.py @@ -1,114 +1,143 @@ -from typing import Tuple, Union +from typing import Optional, Tuple, Union import numpy as np import numpy.ma as ma from numpy.typing import ArrayLike +from foapy.core._factorize import _normalize_sequence_axis from foapy.core._order import order as core_order -from foapy.exceptions import Not1DArrayException +from foapy.partials._factorize import _stable_partial_factorize def order( X: ArrayLike, return_alphabet: bool = False, + *, + axis: Optional[int] = None, ) -> Union[ma.MaskedArray, Tuple[ma.MaskedArray, np.ndarray]]: """ - Map a partial sequence to its order, preserving gap positions. + Map a dense or partial sequence to its one-dimensional order. - Unlike :func:`foapy.order`, this function returns a masked array aligned - with the input. Masked positions are gaps: they are excluded from the - alphabet and remain masked in the result. Plain sequences are treated as - fully unmasked inputs. + With an explicit ``axis``, each complete orthogonal slice is one element. + A slice must be wholly present or wholly masked. Wholly masked slices are + excluded from the alphabet and remain masked in the order. Plain sequences + are treated as fully unmasked. Parameters ---------- X : array_like or numpy.ma.MaskedArray - 1-D sequence (plain or masked). Masked positions are treated as gaps - and are preserved in the output. + Sequence (plain or masked). If ``axis`` is omitted it must be 1-D. return_alphabet : bool, optional - If True, also return the alphabet of non-masked unique values. + If True, also return the alphabet of present unique elements. + axis : int, optional + Sequence axis. Negative axes follow NumPy conventions. The returned + alphabet retains this axis in the same position. Returns ------- result : numpy.ma.MaskedArray, shape (n,), dtype numpy.intp - Masked 1-D array of the same length as X. Non-masked positions hold - the element's 0-based alphabet index (first-appearance order). - Masked positions are identical to the input mask. - alphabet : numpy.ndarray, shape (p,) - Only returned when return_alphabet=True. Unique non-masked values in - first-appearance order. p = number of unique non-masked values. + One-dimensional order with length ``X.shape[axis]`` for an explicit + axis, or ``len(X)`` otherwise. Present positions hold first-appearance + alphabet indices; whole-slice gaps remain masked. + alphabet : numpy.ndarray + Only returned when ``return_alphabet=True``. Plain array of unique + present elements. For an explicit axis it retains the input rank and + selected axis placement. Raises ------ Not1DArrayException - When X has more than one dimension. + When ``X`` is scalar, or is multidimensional without an explicit axis. + ValueError + When a slice along an explicit axis is only partially masked. + numpy.exceptions.AxisError + When an explicit axis is out of range. Examples -------- - Get an order from a plain sequence. The result is a masked array even - though the input has no gaps. + Preserve gaps in a scalar sequence: ``` py linenums="1" + import numpy.ma as ma import foapy - source = ['a', 'b', 'a', 'c'] - result = foapy.partials.order(source) - print(result) - # [0, 1, 0, 2] + source = ma.masked_array( + ['a', 'x', 'b', 'a'], mask=[False, True, False, False] + ) + result, alphabet = foapy.partials.order(source, return_alphabet=True) + print(result, alphabet) + # [0 -- 1 0] ['a' 'b'] ``` - Preserve gaps while ordering the non-masked values. + Factorize complete rows while preserving a wholly masked row as a gap: ``` py linenums="1" import numpy.ma as ma import foapy source = ma.masked_array( - ['a', 'x', 'b', 'a'], mask=[False, True, False, False] + [[1, 2], [9, 9], [3, 4], [1, 2]], + mask=[[0, 0], [1, 1], [0, 0], [0, 0]], + ) + result, alphabet = foapy.partials.order( + source, return_alphabet=True, axis=0 ) - result = foapy.partials.order(source) print(result) # [0 -- 1 0] + print(alphabet) + # [[1 2] + # [3 4]] ``` - Return the partial order and the alphabet of non-masked values. + Reconstruct the observed columns and broadcast the order mask: ``` py linenums="1" + import numpy as np import numpy.ma as ma - import foapy - source = ma.masked_array( - ['a', 'x', 'b', 'a'], mask=[False, True, False, False] - ) - result, alphabet = foapy.partials.order(source, return_alphabet=True) - print(result, alphabet) - # [0 -- 1 0] ['a' 'b'] + restored_data = np.take(alphabet, result.filled(0), axis=0) + restored_mask = np.broadcast_to(result.mask[:, None], source.shape) + restored = ma.masked_array(restored_data, mask=restored_mask) + print(ma.allequal(restored, source)) + # True ``` + + Values stored underneath gap masks are intentionally not part of the + partial sequence. Plain or fully unmasked inputs have the same alphabet + and non-masked order values as their :mod:`foapy.core` counterparts. """ - ar = ma.asarray(X) + data = ma.asarray(X) - if ar.ndim > 1: - raise Not1DArrayException( - {"message": f"Incorrect array form. Expected d1 array, exists {ar.ndim}"} - ) + if data.ndim != 1: + result, alphabet = _stable_partial_factorize(data, axis=axis) - n = len(ar) - full_mask = ma.getmaskarray(ar) - compressed = ar.compressed() + if return_alphabet: + return result, alphabet + return result + + if axis is not None: + _normalize_sequence_axis(data, axis) + + sequence_length = len(data) + full_mask = ma.getmaskarray(data) + compressed = data.compressed() if len(compressed) == 0: - result_data = np.zeros(n, dtype=np.intp) + result_data = np.zeros(sequence_length, dtype=np.intp) result = ma.masked_array(result_data, mask=full_mask) if return_alphabet: - return result, np.array([], dtype=ar.dtype) + return result, np.array([], dtype=data.dtype) return result - order_compressed, alphabet_values = core_order(compressed, return_alphabet=True) + if return_alphabet: + compressed_order, alphabet = core_order(compressed, return_alphabet=True) + else: + compressed_order = core_order(compressed) - result_data = np.full(n, -1, dtype=np.intp) - result_data[~full_mask] = order_compressed + result_data = np.full(sequence_length, -1, dtype=np.intp) + result_data[~full_mask] = compressed_order result = ma.masked_array(result_data, mask=full_mask) if return_alphabet: - return result, alphabet_values + return result, alphabet return result diff --git a/tests/test_axis_alphabet_order.py b/tests/test_axis_alphabet_order.py new file mode 100644 index 00000000..f21bcc8a --- /dev/null +++ b/tests/test_axis_alphabet_order.py @@ -0,0 +1,302 @@ +import importlib + +import numpy as np +import pytest +from numpy.testing import assert_array_equal + +from foapy.core import alphabet, order +from foapy.exceptions import Not1DArrayException + +try: + from numpy.exceptions import AxisError +except ImportError: # NumPy < 1.25 + from numpy import AxisError + + +def test_explicit_axis_preserves_stable_scalar_factorization(): + source = np.array(["b", "a", "b", "c"]) + + assert_array_equal(alphabet(source, axis=0), ["b", "a", "c"]) + assert_array_equal(order(source, axis=-1), [0, 1, 0, 2]) + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +def test_1d_calls_do_not_use_multidimensional_factorizer(monkeypatch, axis): + alphabet_module = importlib.import_module("foapy.core._alphabet") + order_module = importlib.import_module("foapy.core._order") + + def fail(*args, **kwargs): + pytest.fail("one-dimensional input entered multidimensional factorization") + + monkeypatch.setattr(alphabet_module, "_stable_factorize", fail) + monkeypatch.setattr(order_module, "_stable_factorize", fail) + + source = np.array(["b", "a", "b", "c"]) + assert_array_equal(alphabet(source, axis=axis), ["b", "a", "c"]) + result, result_alphabet = order(source, True, axis=axis) + assert_array_equal(result, [0, 1, 0, 2]) + assert_array_equal(result_alphabet, ["b", "a", "c"]) + + +@pytest.mark.parametrize( + ("axis", "expected_order", "expected_alphabet"), + [ + ( + 0, + [0, 1, 0], + [["a", "b", "a", "x"], ["c", "d", "c", "y"]], + ), + ( + 1, + [0, 1, 0, 2], + [["a", "b", "x"], ["c", "d", "y"], ["a", "b", "x"]], + ), + ], +) +def test_2d_slice_elements(axis, expected_order, expected_alphabet): + source = np.array( + [ + ["a", "b", "a", "x"], + ["c", "d", "c", "y"], + ["a", "b", "a", "x"], + ] + ) + + result, result_alphabet = order(source, True, axis=axis) + + assert_array_equal(result, expected_order) + assert_array_equal(result_alphabet, expected_alphabet) + assert_array_equal(alphabet(source, axis=axis), result_alphabet) + assert_array_equal(np.take(result_alphabet, result, axis=axis), source) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_3d_slice_elements_on_every_axis(axis): + source_alphabet = np.take(np.arange(12).reshape(2, 2, 3), [0, 1], axis=axis) + source = np.take(source_alphabet, [1, 0, 1], axis=axis) + expected_alphabet = np.take(source_alphabet, [1, 0], axis=axis) + + result, result_alphabet = order(source, True, axis=axis) + + assert result.shape == (3,) + assert result.dtype == np.dtype(np.intp) + assert_array_equal(result, [0, 1, 0]) + assert_array_equal(result_alphabet, expected_alphabet) + assert_array_equal(np.take(result_alphabet, result, axis=axis), source) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_negative_axis_matches_positive_axis(axis): + source = np.take(np.arange(12).reshape(2, 2, 3), [1, 0, 1], axis=axis) + negative_axis = axis - source.ndim + + positive_order, positive_alphabet = order(source, True, axis=axis) + negative_order, negative_alphabet = order(source, True, axis=negative_axis) + + assert_array_equal(negative_order, positive_order) + assert_array_equal(negative_alphabet, positive_alphabet) + + +def test_first_appearance_order_is_not_sorted_order(): + source = np.array([[9, 9], [1, 1], [9, 9], [5, 5]]) + + result, result_alphabet = order(source, True, axis=0) + + assert_array_equal(result, [0, 1, 0, 2]) + assert_array_equal(result_alphabet, [[9, 9], [1, 1], [5, 5]]) + + +@pytest.mark.parametrize( + ("shape", "axis"), + [((0, 2), 0), ((2, 0), 1), ((2, 0, 3), 1)], +) +def test_empty_sequence_axis(shape, axis): + source = np.empty(shape, dtype=np.int64) + + result, result_alphabet = order(source, True, axis=axis) + + assert result.shape == (0,) + assert result.dtype == np.dtype(np.intp) + assert result_alphabet.shape == shape + assert result_alphabet.dtype == source.dtype + + +@pytest.mark.parametrize(("shape", "axis"), [((3, 0), 0), ((0, 3), 1)]) +def test_structurally_empty_slices_are_one_repeated_element(shape, axis): + source = np.empty(shape, dtype=np.float64) + + result, result_alphabet = order(source, True, axis=axis) + + assert_array_equal(result, [0, 0, 0]) + expected_shape = list(shape) + expected_shape[axis] = 1 + assert result_alphabet.shape == tuple(expected_shape) + assert_array_equal(np.take(result_alphabet, result, axis=axis), source) + + +@pytest.mark.parametrize("function", [alphabet, order]) +def test_multidimensional_input_without_axis_keeps_legacy_error(function): + with pytest.raises(Not1DArrayException): + function(np.array([[1, 2], [3, 4]])) + + +@pytest.mark.parametrize("function", [alphabet, order]) +def test_scalar_input_is_rejected(function): + with pytest.raises(Not1DArrayException): + function(np.array(1), axis=0) + + +@pytest.mark.parametrize("function", [alphabet, order]) +@pytest.mark.parametrize("axis", [-3, 2]) +def test_out_of_range_axis_raises_numpy_axis_error(function, axis): + with pytest.raises(AxisError): + function(np.ones((2, 2)), axis=axis) + + +def test_numeric_and_string_dtypes_are_preserved(): + numeric = np.array([[2, 1], [3, 4], [2, 1]], dtype=np.int16) + strings = np.array([["b", "a"], ["c", "d"], ["b", "a"]]) + + assert alphabet(numeric, axis=0).dtype == numeric.dtype + assert alphabet(strings, axis=0).dtype == strings.dtype + + +@pytest.mark.parametrize( + ("source", "expected_order"), + [ + ( + np.array([[-0.0, 1.0], [0.0, 1.0], [-0.0, 1.0]]), + [0, 0, 0], + ), + ( + np.array( + [ + [complex(-0.0, 1.0)], + [complex(0.0, 1.0)], + [complex(0.0, -0.0)], + [complex(0.0, 0.0)], + ] + ), + [0, 0, 1, 1], + ), + ], +) +def test_hash_factorization_preserves_signed_zero_equality( + monkeypatch, source, expected_order +): + factorize_module = importlib.import_module("foapy.core._factorize") + monkeypatch.setattr(factorize_module, "_HASH_MIN_RECORD_BYTES", 0) + original_bytes = source.view(np.uint8).copy() + + result, result_alphabet = order(source, True, axis=0) + + assert_array_equal(result, expected_order) + assert_array_equal(np.take(result_alphabet, result, axis=0), source) + assert_array_equal(source.view(np.uint8), original_bytes) + + +def test_signed_zero_normalization_reuses_private_contiguous_records(monkeypatch): + factorize_module = importlib.import_module("foapy.core._factorize") + source = np.array([[-0.0, 2.0, 4.0], [1.0, 3.0, 5.0]]) + moved = np.moveaxis(source, 1, 0) + original_ascontiguousarray = np.ascontiguousarray + original_digest_records = factorize_module._digest_records + captured = {} + + def capture_records(array): + records = original_ascontiguousarray(array) + captured["records"] = records + assert not np.may_share_memory(records, moved) + return records + + def check_reused_records(record_bytes): + assert np.shares_memory(record_bytes, captured["records"]) + return original_digest_records(record_bytes) + + monkeypatch.setattr(factorize_module.np, "ascontiguousarray", capture_records) + monkeypatch.setattr(factorize_module, "_digest_records", check_reused_records) + monkeypatch.setattr(factorize_module, "_HASH_MIN_RECORD_BYTES", 0) + + factorize_module._slice_digests(moved) + + +def test_hash_factorization_preserves_nan_record_behavior(monkeypatch): + factorize_module = importlib.import_module("foapy.core._factorize") + monkeypatch.setattr(factorize_module, "_HASH_MIN_RECORD_BYTES", 0) + source = np.array([[np.nan, 1.0], [np.nan, 1.0], [2.0, 3.0]]) + + result, result_alphabet = order(source, True, axis=0) + + assert_array_equal(result, [0, 1, 2]) + assert result_alphabet.shape == source.shape + + +def test_hash_collision_falls_back_to_exact_factorization(monkeypatch): + factorize_module = importlib.import_module("foapy.core._factorize") + exact_factorize = factorize_module._factorize_unique_slices + exact_calls = [] + + def collide(record_bytes): + return np.zeros(record_bytes.shape[0], dtype="V16") + + def tracked_exact_factorize(data, axis): + exact_calls.append((data, axis)) + return exact_factorize(data, axis) + + monkeypatch.setattr(factorize_module, "_digest_records", collide) + monkeypatch.setattr(factorize_module, "_HASH_MIN_RECORD_BYTES", 0) + monkeypatch.setattr( + factorize_module, "_factorize_unique_slices", tracked_exact_factorize + ) + source = np.array([[2, 1], [3, 4], [2, 1], [5, 6]]) + + result, result_alphabet = order(source, True, axis=0) + + assert_array_equal(result, [0, 1, 0, 2]) + assert_array_equal(result_alphabet, [[2, 1], [3, 4], [5, 6]]) + assert len(exact_calls) == 1 + + +def test_digest_records_returns_one_xxh3_128_value_per_record(): + factorize_module = importlib.import_module("foapy.core._factorize") + record_bytes = np.array([[0, 1, 2, 3], [4, 5, 6, 7], [0, 1, 2, 3]], dtype=np.uint8) + + digests = factorize_module._digest_records(record_bytes) + + assert digests.shape == (3,) + assert digests.dtype == np.dtype("V16") + assert digests[0] == digests[2] + assert digests[0] != digests[1] + + +def test_narrow_numeric_slices_skip_hash_factorization(monkeypatch): + factorize_module = importlib.import_module("foapy.core._factorize") + + def fail(record_bytes): + pytest.fail("narrow slices entered hash factorization") + + monkeypatch.setattr(factorize_module, "_digest_records", fail) + source = np.array([[2, 1], [3, 4], [2, 1]]) + + result, result_alphabet = order(source, True, axis=0) + + assert_array_equal(result, [0, 1, 0]) + assert_array_equal(result_alphabet, source[:2]) + + +def test_structured_dtype_uses_exact_factorization(monkeypatch): + factorize_module = importlib.import_module("foapy.core._factorize") + + def fail(record_bytes): + pytest.fail("structured dtype entered byte-hash factorization") + + monkeypatch.setattr(factorize_module, "_digest_records", fail) + source = np.array( + [[(2, 1.0)], [(3, 4.0)], [(2, 1.0)]], + dtype=[("left", np.int16), ("right", np.float64)], + ) + + result, result_alphabet = order(source, True, axis=0) + + assert_array_equal(result, [0, 1, 0]) + assert_array_equal(result_alphabet, source[:2]) diff --git a/tests/test_axis_intervals_chain.py b/tests/test_axis_intervals_chain.py new file mode 100644 index 00000000..50bcf8a1 --- /dev/null +++ b/tests/test_axis_intervals_chain.py @@ -0,0 +1,120 @@ +import importlib + +import numpy as np +import pytest +from numpy.testing import assert_array_equal + +from foapy import binding, chain_mode +from foapy.core import intervals_chain, order +from foapy.exceptions import Not1DArrayException + +try: + from numpy.exceptions import AxisError +except ImportError: # NumPy < 1.25 + from numpy import AxisError + + +EXPECTED = { + (binding.start, chain_mode.boundary): [1, 2, 2, 4, 2], + (binding.start, chain_mode.cycle): [1, 5, 2, 5, 2], + (binding.end, chain_mode.boundary): [2, 4, 2, 2, 1], + (binding.end, chain_mode.cycle): [2, 5, 2, 5, 1], +} + + +@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("binding_value,chain_mode_value", EXPECTED) +def test_rows_and_columns_are_sequence_elements(axis, binding_value, chain_mode_value): + rows = np.array([[1, 2], [3, 4], [1, 2], [5, 6], [1, 2]]) + source = rows if axis == 0 else rows.T + + result = intervals_chain(source, binding_value, chain_mode_value, axis=axis) + + assert_array_equal(result, EXPECTED[(binding_value, chain_mode_value)]) + assert result.shape == (5,) + assert result.dtype == np.dtype(np.intp) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize("chain_mode_value", [chain_mode.boundary, chain_mode.cycle]) +def test_3d_result_matches_interval_chain_of_axis_order( + axis, binding_value, chain_mode_value +): + source_alphabet = np.take(np.arange(12).reshape(2, 2, 3), [0, 1], axis=axis) + source = np.take(source_alphabet, [0, 1, 0, 1, 0], axis=axis) + + result = intervals_chain(source, binding_value, chain_mode_value, axis=axis) + expected = intervals_chain( + order(source, axis=axis), binding_value, chain_mode_value + ) + + assert_array_equal(result, expected) + assert result.shape == (5,) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_negative_axis_matches_positive_axis(axis): + source = np.take(np.arange(12).reshape(2, 2, 3), [0, 1, 0], axis=axis) + negative_axis = axis - source.ndim + + positive = intervals_chain(source, binding.start, chain_mode.boundary, axis=axis) + negative = intervals_chain( + source, binding.start, chain_mode.boundary, axis=negative_axis + ) + + assert_array_equal(negative, positive) + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +def test_1d_calls_use_legacy_direct_path(monkeypatch, axis): + module = importlib.import_module("foapy.core._intervals_chain") + + def fail(*args, **kwargs): + pytest.fail("one-dimensional input entered axis factorization") + + monkeypatch.setattr(module, "core_order", fail) + source = np.array(["b", "a", "b", "c", "b"]) + + result = intervals_chain(source, binding.start, chain_mode.boundary, axis=axis) + + assert_array_equal(result, [1, 2, 2, 4, 2]) + + +def test_multidimensional_input_without_axis_keeps_legacy_error(): + with pytest.raises(Not1DArrayException): + intervals_chain(np.ones((2, 2)), binding.start, chain_mode.boundary) + + +def test_scalar_input_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_chain(np.array(1), binding.start, chain_mode.boundary, axis=0) + + +@pytest.mark.parametrize("axis", [-3, 2]) +def test_out_of_range_axis_raises_numpy_axis_error(axis): + with pytest.raises(AxisError): + intervals_chain(np.ones((2, 2)), binding.start, chain_mode.boundary, axis=axis) + + +@pytest.mark.parametrize( + "shape,axis", + [((0, 2), 0), ((2, 0), 1), ((2, 0, 3), 1)], +) +def test_empty_sequence_axis_returns_empty_intp_chain(shape, axis): + result = intervals_chain( + np.empty(shape), binding.start, chain_mode.boundary, axis=axis + ) + + assert result.shape == (0,) + assert result.dtype == np.dtype(np.intp) + + +@pytest.mark.parametrize("shape,axis", [((4, 0), 0), ((0, 4), 1)]) +def test_structurally_empty_slices_are_one_repeated_element(shape, axis): + result = intervals_chain( + np.empty(shape), binding.start, chain_mode.boundary, axis=axis + ) + + assert_array_equal(result, [1, 1, 1, 1]) + assert result.dtype == np.dtype(np.intp) diff --git a/tests/test_axis_intervals_distribution.py b/tests/test_axis_intervals_distribution.py new file mode 100644 index 00000000..7b63d76c --- /dev/null +++ b/tests/test_axis_intervals_distribution.py @@ -0,0 +1,197 @@ +import importlib + +import numpy as np +import numpy.ma as ma +import pytest +from numpy.ma.testutils import assert_equal +from numpy.testing import assert_array_equal + +from foapy import binding, tuple_mode +from foapy.core import intervals_distribution, intervals_tuple +from foapy.exceptions import Not1DArrayException + +try: + from numpy.exceptions import AxisError +except ImportError: # NumPy < 1.25 + from numpy import AxisError + + +TUPLES = np.array([[1, 1, 3, 1], [1, 2, 1, 3]]) +EXPECTED_ROWS = np.array([[3, 0, 1], [2, 1, 1]]) + + +@pytest.mark.parametrize("axis", [0, 1]) +def test_rows_and_columns_have_independent_distributions(axis): + source = TUPLES if axis == 1 else TUPLES.T + expected = EXPECTED_ROWS if axis == 1 else EXPECTED_ROWS.T + + result = intervals_distribution(source, axis=axis) + + assert ma.isMaskedArray(result) + assert_equal(result, ma.masked_array(expected, mask=False)) + assert result.dtype == np.dtype(np.intp) + + +def _three_dimensional_tuples(axis): + lanes = np.stack([TUPLES[index % 2] for index in range(6)]).reshape(2, 3, 4) + return np.moveaxis(lanes, -1, axis) + + +def _three_dimensional_expected(axis): + lanes = np.stack([EXPECTED_ROWS[index % 2] for index in range(6)]).reshape(2, 3, 3) + return np.moveaxis(lanes, -1, axis) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_3d_axis_replaces_selected_dimension(axis): + source = _three_dimensional_tuples(axis) + expected = _three_dimensional_expected(axis) + + result = intervals_distribution(source, axis=axis) + + assert_equal(result, ma.masked_array(expected, mask=False)) + assert result.shape[axis] == 3 + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_negative_axis_matches_positive_axis(axis): + source = _three_dimensional_tuples(axis) + + positive = intervals_distribution(source, axis=axis) + negative = intervals_distribution(source, axis=axis - source.ndim) + + assert_equal(negative, positive) + + +def test_unequal_maximums_mask_only_trailing_bins(): + source = np.array([[1, 3], [1, 2]]) + expected = ma.masked_array( + [[1, 0, 1], [1, 1, 0]], + mask=[[False, False, False], [False, False, True]], + ) + + result = intervals_distribution(source, axis=1) + + assert_equal(result, expected) + assert result.mask[0, 1] is np.False_ + assert result[0, 1] == 0 + + +def test_masked_tuple_padding_is_excluded(): + source = ma.masked_array( + [[1, 1, 1, 0], [1, 2, 3, 4]], + mask=[[False, False, False, True], [False, False, False, False]], + ) + expected = ma.masked_array( + [[3, 0, 0, 0], [1, 1, 1, 1]], + mask=[[False, True, True, True], [False, False, False, False]], + ) + + result = intervals_distribution(source, axis=1) + + assert_equal(result, expected) + + +def test_fully_masked_lane_is_trailing_masked(): + source = ma.masked_array( + [[0, 0, 0, 0], [1, 2, 3, 4]], + mask=[[True, True, True, True], [False, False, False, False]], + ) + + result = intervals_distribution(source, axis=1) + + assert np.all(result.mask[0]) + assert_equal(result[1], [1, 1, 1, 1]) + + +def test_all_empty_lanes_return_empty_selected_axis(): + result = intervals_distribution(np.empty((2, 0), dtype=np.intp), axis=1) + + assert ma.isMaskedArray(result) + assert result.shape == (2, 0) + + +@pytest.mark.parametrize("shape,axis", [((0, 4), 1), ((4, 0), 0)]) +def test_no_lanes_returns_zero_length_result_axis(shape, axis): + result = intervals_distribution(np.empty(shape, dtype=np.intp), axis=axis) + + assert ma.isMaskedArray(result) + assert result.shape == (0, 0) + + +def test_1d_masked_tuple_excludes_masked_positions(): + source = ma.masked_array([1, 9, 3], mask=[False, True, False]) + + result = intervals_distribution(source) + + assert_array_equal(result, [1, 0, 1]) + assert not ma.isMaskedArray(result) + + +def test_prepared_1d_plain_array_skips_conversion(monkeypatch): + module = importlib.import_module("foapy.core._intervals_distribution") + prepared = np.array([1, 1, 3, 1], dtype=np.intp) + + def fail_conversion(*args, **kwargs): + raise AssertionError("prepared ndarray was converted again") + + monkeypatch.setattr(module.np, "asanyarray", fail_conversion) + + assert module.intervals_distribution(prepared).tolist() == [3, 0, 1] + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +def test_1d_calls_use_direct_plain_array_path(monkeypatch, axis): + module = importlib.import_module("foapy.core._intervals_distribution") + + def fail(*args, **kwargs): + pytest.fail("one-dimensional input entered multidimensional packing") + + monkeypatch.setattr(module, "_apply_to_axis_lanes", fail) + + result = intervals_distribution(np.array([1, 1, 3, 1]), axis=axis) + + assert_array_equal(result, [3, 0, 1]) + assert not ma.isMaskedArray(result) + assert result.dtype == np.dtype(np.intp) + + +def test_multidimensional_input_without_axis_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_distribution(np.ones((2, 2), dtype=np.intp)) + + +def test_scalar_input_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_distribution(np.array(1), axis=0) + + +@pytest.mark.parametrize("axis", [-3, 2]) +def test_invalid_axis_raises_numpy_axis_error(axis): + with pytest.raises(AxisError): + intervals_distribution(np.ones((2, 2), dtype=np.intp), axis=axis) + + +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +@pytest.mark.parametrize("axis", [0, 1]) +def test_axis_tuple_output_composes_with_distribution(tuple_mode_value, axis): + rows = np.array([[1, 1, 3, 1], [1, 2, 1, 3]]) + source = rows if axis == 1 else rows.T + tuple_result = intervals_tuple(source, binding.start, tuple_mode_value, axis=axis) + + result = intervals_distribution(tuple_result, axis=axis) + + lane_results = [ + intervals_distribution(intervals_tuple(row, binding.start, tuple_mode_value)) + for row in rows + ] + width = max(lane.size for lane in lane_results) + expected_rows = ma.masked_all((len(lane_results), width), dtype=np.intp) + for index, lane in enumerate(lane_results): + expected_rows.data[index, : lane.size] = lane + expected_rows.mask[index, : lane.size] = False + expected = expected_rows if axis == 1 else expected_rows.T + + assert_equal(result, expected) diff --git a/tests/test_axis_intervals_tuple.py b/tests/test_axis_intervals_tuple.py new file mode 100644 index 00000000..c6e593bc --- /dev/null +++ b/tests/test_axis_intervals_tuple.py @@ -0,0 +1,409 @@ +import importlib + +import numpy as np +import numpy.ma as ma +import pytest +from numpy.ma.testutils import assert_equal +from numpy.testing import assert_array_equal + +import foapy +import foapy.core as core +from foapy import binding, tuple_mode +from foapy.core import intervals_tuple +from foapy.core._intervals_chain_validation import is_valid_intervals_chain +from foapy.exceptions import Not1DArrayException + +try: + from numpy.exceptions import AxisError +except ImportError: # NumPy < 1.25 + from numpy import AxisError + + +CHAINS = { + binding.start: np.array([[1, 1, 3, 1], [1, 2, 1, 3]]), + binding.end: np.array([[1, 3, 1, 1], [3, 1, 2, 1]]), +} +EXPECTED = { + (binding.start, tuple_mode.normal): [[1, 1, 3, 1], [1, 2, 1, 3]], + (binding.end, tuple_mode.normal): [[1, 3, 1, 1], [3, 1, 2, 1]], + (binding.start, tuple_mode.lossy): [[1, 1], [1, 3]], + (binding.end, tuple_mode.lossy): [[1, 1], [1, 3]], + (binding.start, tuple_mode.redundant): [ + [1, 1, 3, 1, 3, 1], + [1, 2, 1, 3, 2, 1], + ], + (binding.end, tuple_mode.redundant): [ + [1, 1, 3, 1, 3, 1], + [1, 2, 1, 3, 2, 1], + ], +} + + +@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_rows_and_columns_are_independent_chains(axis, binding_value, tuple_mode_value): + rows = CHAINS[binding_value] + source = rows if axis == 1 else rows.T + expected_rows = np.array(EXPECTED[(binding_value, tuple_mode_value)]) + expected = expected_rows if axis == 1 else expected_rows.T + + result = intervals_tuple(source, binding_value, tuple_mode_value, axis=axis) + + assert ma.isMaskedArray(result) + assert_equal(result, ma.masked_array(expected, mask=False)) + assert result.dtype == np.dtype(np.intp) + + +def _three_dimensional_chains(axis, binding_value): + lanes = np.stack([CHAINS[binding_value][index % 2] for index in range(6)]).reshape( + 2, 3, 4 + ) + return np.moveaxis(lanes, -1, axis) + + +def _three_dimensional_expected(axis, binding_value, tuple_mode_value): + lane_results = np.stack( + [ + intervals_tuple( + CHAINS[binding_value][index % 2], + binding_value, + tuple_mode_value, + ) + for index in range(6) + ] + ).reshape(2, 3, -1) + return np.moveaxis(lane_results, -1, axis) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_3d_axis_replaces_selected_dimension(axis, binding_value, tuple_mode_value): + source = _three_dimensional_chains(axis, binding_value) + expected = _three_dimensional_expected(axis, binding_value, tuple_mode_value) + + result = intervals_tuple(source, binding_value, tuple_mode_value, axis=axis) + + assert_equal(result, ma.masked_array(expected, mask=False)) + assert result.shape[axis] == expected.shape[axis] + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_negative_axis_matches_positive_axis(axis): + source = _three_dimensional_chains(axis, binding.start) + + positive = intervals_tuple(source, binding.start, tuple_mode.redundant, axis=axis) + negative = intervals_tuple( + source, binding.start, tuple_mode.redundant, axis=axis - source.ndim + ) + + assert_equal(negative, positive) + + +@pytest.mark.parametrize( + "tuple_mode_value,expected", + [ + ( + tuple_mode.lossy, + ma.masked_array( + [[1, 1, 1], [0, 0, 0]], + mask=[[False, False, False], [True, True, True]], + ), + ), + ( + tuple_mode.redundant, + ma.masked_array( + [ + [1, 1, 1, 1, 1, 0, 0, 0], + [1, 2, 3, 4, 4, 3, 2, 1], + ], + mask=[ + [False, False, False, False, False, True, True, True], + [False] * 8, + ], + ), + ), + ], +) +def test_variable_length_results_are_trailing_masked(tuple_mode_value, expected): + source = np.array([[1, 1, 1, 1], [1, 2, 3, 4]]) + + result = intervals_tuple(source, binding.start, tuple_mode_value, axis=1) + + assert_equal(result, expected) + + +def test_uniform_results_still_return_masked_array(): + result = intervals_tuple( + CHAINS[binding.start], binding.start, tuple_mode.lossy, axis=1 + ) + + assert ma.isMaskedArray(result) + assert not np.any(ma.getmaskarray(result)) + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_1d_calls_use_direct_plain_array_path(monkeypatch, axis, tuple_mode_value): + module = importlib.import_module("foapy.core._intervals_tuple") + + def fail(*args, **kwargs): + pytest.fail("one-dimensional input entered multidimensional packing") + + monkeypatch.setattr(module, "_apply_to_axis_lanes", fail) + source = np.array([1, 1, 3, 1]) + + result = intervals_tuple(source, binding.start, tuple_mode_value, axis=axis) + + assert isinstance(result, np.ndarray) + assert not ma.isMaskedArray(result) + assert result.dtype == np.dtype(np.intp) + + +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_empty_1d_input_stays_plain(tuple_mode_value): + result = intervals_tuple( + np.array([], dtype=np.intp), + binding.start, + tuple_mode_value, + axis=0, + ) + + assert_array_equal(result, np.array([], dtype=np.intp)) + assert not ma.isMaskedArray(result) + + +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_empty_selected_axis_returns_empty_masked_result(tuple_mode_value): + result = intervals_tuple( + np.empty((2, 0, 3), dtype=np.intp), + binding.start, + tuple_mode_value, + axis=1, + ) + + assert ma.isMaskedArray(result) + assert result.shape == (2, 0, 3) + + +@pytest.mark.parametrize( + "tuple_mode_value,expected_shape", + [ + (tuple_mode.normal, (0, 4)), + (tuple_mode.lossy, (0, 0)), + (tuple_mode.redundant, (0, 0)), + ], +) +def test_no_lanes_has_deterministic_shape(tuple_mode_value, expected_shape): + result = intervals_tuple( + np.empty((0, 4), dtype=np.intp), + binding.start, + tuple_mode_value, + axis=1, + ) + + assert ma.isMaskedArray(result) + assert result.shape == expected_shape + + +def test_multidimensional_input_without_axis_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_tuple(np.ones((2, 2)), binding.start, tuple_mode.normal) + + +def test_scalar_input_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_tuple(np.array(1), binding.start, tuple_mode.normal, axis=0) + + +@pytest.mark.parametrize("axis", [-3, 2]) +def test_invalid_axis_raises_numpy_axis_error(axis): + with pytest.raises(AxisError): + intervals_tuple(np.ones((2, 2)), binding.start, tuple_mode.normal, axis=axis) + + +def test_validator_is_not_exported(): + assert not hasattr(foapy, "is_valid_intervals_chain") + assert not hasattr(core, "is_valid_intervals_chain") + assert "is_valid_intervals_chain" not in foapy.__all__ + assert "is_valid_intervals_chain" not in core.__all__ + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +def test_internal_validator_accepts_1d_input(axis): + assert is_valid_intervals_chain(np.array([9, -2, 0]), axis=axis) is True + + +def test_internal_validator_accepts_array_like_input(): + assert is_valid_intervals_chain([9, -2, 0]) is True + + +def test_internal_validator_prepared_1d_fast_path_skips_conversion(monkeypatch): + module = importlib.import_module("foapy.core._intervals_chain_validation") + prepared = np.array([9, -2, 0]) + + def fail_conversion(*args, **kwargs): + raise AssertionError("prepared ndarray was converted again") + + monkeypatch.setattr(module.np, "asanyarray", fail_conversion) + + assert module.is_valid_intervals_chain(prepared) is True + + +def test_internal_validator_aggregates_multidimensional_lanes(monkeypatch): + module = importlib.import_module("foapy.core._intervals_chain_validation") + seen = [] + + def record(lanes): + seen.append(lanes.copy()) + return True + + monkeypatch.setattr(module, "_are_valid_intervals_chain_lanes", record) + + assert is_valid_intervals_chain(np.arange(12).reshape(3, 4), axis=1) is True + assert len(seen) == 1 + assert_array_equal(seen[0], np.arange(12).reshape(3, 4)) + + +def test_internal_validator_structural_errors(): + with pytest.raises(Not1DArrayException): + is_valid_intervals_chain(np.ones((2, 2))) + with pytest.raises(Not1DArrayException): + is_valid_intervals_chain(np.array(1), axis=0) + with pytest.raises(AxisError): + is_valid_intervals_chain(np.ones((2, 2)), axis=2) + + +def test_intervals_tuple_rejects_false_validation(monkeypatch): + module = importlib.import_module("foapy.core._intervals_tuple") + monkeypatch.setattr( + module, "is_valid_intervals_chain", lambda *args, **kwargs: False + ) + + with pytest.raises(ValueError, match="Invalid intervals chain"): + intervals_tuple([1, 1, 1], binding.start, tuple_mode.normal) + + +def test_intervals_tuple_prepares_1d_input_once(monkeypatch): + module = importlib.import_module("foapy.core._intervals_tuple") + original = module.np.asanyarray + calls = [] + + def record_conversion(*args, **kwargs): + calls.append((args, kwargs)) + return original(*args, **kwargs) + + monkeypatch.setattr(module.np, "asanyarray", record_conversion) + + result = module.intervals_tuple([1, 1, 1], binding.start, tuple_mode.normal) + + assert len(calls) == 1 + assert result.tolist() == [1, 1, 1] + + +def test_intervals_tuple_reuses_prepared_1d_input(monkeypatch): + module = importlib.import_module("foapy.core._intervals_tuple") + chain = np.array([1, 1, 1], dtype=np.intp) + + def fail_conversion(*args, **kwargs): + raise AssertionError("prepared ndarray was converted again") + + monkeypatch.setattr(module.np, "asanyarray", fail_conversion) + + result = module.intervals_tuple(chain, binding.start, tuple_mode.normal) + + assert result.tolist() == [1, 1, 1] + + +def test_intervals_tuple_normalizes_explicit_1d_axis_once(monkeypatch): + module = importlib.import_module("foapy.core._intervals_tuple") + original = module._normalize_sequence_axis + seen = [] + + def record_normalization(data, axis): + seen.append(axis) + return original(data, axis) + + monkeypatch.setattr(module, "_normalize_sequence_axis", record_normalization) + + result = module.intervals_tuple( + np.array([1, 1, 1]), + binding.start, + tuple_mode.normal, + axis=-1, + ) + + assert_array_equal(result, [1, 1, 1]) + assert seen == [-1] + + +def test_multidimensional_lanes_are_validated_and_transformed_as_one_batch( + monkeypatch, +): + module = importlib.import_module("foapy.core._intervals_tuple") + events = [] + + def validate(lanes, *, axis=None): + assert lanes.ndim == 2 + assert axis == 1 + events.append(("validate", lanes.tolist())) + return True + + def transform(lanes, binding_value, tuple_mode_value): + events.append(("transform", lanes.tolist())) + return ma.masked_array(lanes.copy(), mask=False) + + monkeypatch.setattr(module, "is_valid_intervals_chain", validate) + monkeypatch.setattr(module, "_intervals_tuple_lanes", transform) + + result = module.intervals_tuple( + np.array([[1, 2], [3, 4]]), + binding.start, + tuple_mode.normal, + axis=1, + ) + + assert_equal(result, [[1, 2], [3, 4]]) + assert events == [ + ("validate", [[1, 2], [3, 4]]), + ("transform", [[1, 2], [3, 4]]), + ] + + +def test_false_multidimensional_batch_stops_before_transform(monkeypatch): + module = importlib.import_module("foapy.core._intervals_tuple") + events = [] + + def validate(lanes, *, axis=None): + events.append(("validate", lanes.tolist())) + return False + + def transform(lanes, binding_value, tuple_mode_value): + events.append(("transform", lanes.tolist())) + return ma.masked_array(lanes.copy(), mask=False) + + monkeypatch.setattr(module, "is_valid_intervals_chain", validate) + monkeypatch.setattr(module, "_intervals_tuple_lanes", transform) + + with pytest.raises(ValueError, match="Invalid intervals chain"): + module.intervals_tuple( + np.array([[1, 2], [3, 4], [5, 6]]), + binding.start, + tuple_mode.normal, + axis=1, + ) + + assert events == [ + ("validate", [[1, 2], [3, 4], [5, 6]]), + ] diff --git a/tests/test_axis_vectorization.py b/tests/test_axis_vectorization.py new file mode 100644 index 00000000..f9a4c24e --- /dev/null +++ b/tests/test_axis_vectorization.py @@ -0,0 +1,42 @@ +import ast +from pathlib import Path + +import pytest + +PRODUCTION_MODULES = [ + "src/foapy/core/_axis_transform.py", + "src/foapy/core/_intervals_chain_validation.py", + "src/foapy/core/_intervals_distribution.py", + "src/foapy/core/_intervals_tuple.py", + "src/foapy/partials/_intervals_tuple.py", +] +ITERATION_NODES = ( + ast.For, + ast.AsyncFor, + ast.While, + ast.ListComp, + ast.SetComp, + ast.DictComp, + ast.GeneratorExp, +) +DISGUISED_LOOP_CALLS = {"apply_along_axis", "vectorize"} + + +@pytest.mark.parametrize("relative_path", PRODUCTION_MODULES) +def test_axis_production_modules_use_vectorized_numpy(relative_path): + source_path = Path(__file__).parents[1] / relative_path + tree = ast.parse(source_path.read_text(), filename=str(source_path)) + + iteration_nodes = [ + node for node in ast.walk(tree) if isinstance(node, ITERATION_NODES) + ] + disguised_calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in DISGUISED_LOOP_CALLS + ] + + assert iteration_nodes == [] + assert disguised_calls == [] diff --git a/tests/test_characteristics/characterisitcs_test.py b/tests/test_characteristics/characterisitcs_test.py index 6ebecece..f2a5554a 100644 --- a/tests/test_characteristics/characterisitcs_test.py +++ b/tests/test_characteristics/characterisitcs_test.py @@ -33,7 +33,7 @@ def AssertBatch(self, X, batch, dtype=None): def GetPrecision(self, length, dtype=None): alphabet = np.arange(0, np.fix(length * 0.2), dtype=int) - X = np.random.choice(alphabet, length) + X = np.resize(alphabet, length) intervals_seq = intervals(X, binding.start, mode.normal) return self.target(intervals_seq, dtype) diff --git a/tests/test_order_sort_stability.py b/tests/test_order_sort_stability.py new file mode 100644 index 00000000..5a5c49a7 --- /dev/null +++ b/tests/test_order_sort_stability.py @@ -0,0 +1,130 @@ +from unittest import TestCase + +import numpy as np +from numpy.testing import assert_array_equal + +from foapy import order + + +def reference_order(X): + """Ground truth computed without sorting. + + ``order`` groups equal values with an unstable sort, so it must not be + compared against another sort-based implementation: a shared assumption + about tie ordering would hide exactly the bugs this module targets. + """ + data = np.asarray(X) + seen = {} + result = np.empty(data.shape[0], dtype=np.intp) + alphabet = [] + for position, value in enumerate(data.tolist()): + if value not in seen: + seen[value] = len(alphabet) + alphabet.append(value) + result[position] = seen[value] + return result, np.array(alphabet, dtype=data.dtype) + + +class ReverseStableArray(np.ndarray): + """Array whose ``argsort`` orders ties by descending original index. + + ``order`` only requires its sort to make equal values adjacent, and + recovers first occurrences itself. This subclass supplies the most hostile + permutation satisfying that contract, so any hidden reliance on a stable + sort fails here. ``order`` calls ``np.asanyarray``, which preserves the + subclass, and then ``data.argsort(...)``, which dispatches to this method. + """ + + def argsort(self, *args, **kwargs): + base = np.asarray(self) + return np.lexsort((-np.arange(base.shape[0]), base)) + + +def as_reverse_stable(values): + return np.asarray(values).view(ReverseStableArray) + + +class TestOrderSortStability(TestCase): + """``order`` must not depend on how its sort arranges equal values.""" + + datasets = [ + ["a", "b", "a", "c", "d"], + ["a", "a", "a", "a"], + ["d", "c", "b", "a"], + ["a", "b", "b", "a", "c", "a"], + ["z"], + ["b", "a", "a", "a", "a", "a"], + ["a", "a", "a", "a", "a", "b"], + [1, 2, 3, 2, 1], + [5, 5, 5, 1, 5, 5], + [-1, 0, -1, 3, 0], + ] + + def test_matches_reference_on_datasets(self): + for dataset in self.datasets: + expected_order, expected_alphabet = reference_order(dataset) + exists_order, exists_alphabet = order(dataset, True) + assert_array_equal(expected_order, exists_order) + assert_array_equal(expected_alphabet, exists_alphabet) + + def test_matches_reference_with_reverse_stable_sort(self): + for dataset in self.datasets: + expected_order, expected_alphabet = reference_order(dataset) + exists_order, exists_alphabet = order(as_reverse_stable(dataset), True) + assert_array_equal(expected_order, exists_order) + assert_array_equal(expected_alphabet, exists_alphabet) + + def test_alphabet_is_in_first_appearance_order(self): + # A sorted alphabet would also round-trip, so assert the ordering too. + X = ["d", "c", "a", "c", "b"] + _, exists_alphabet = order(X, True) + assert_array_equal(np.array(["d", "c", "a", "b"]), exists_alphabet) + + def test_last_element_introduces_final_alphabet_entry(self): + # The largest first-occurrence index equals len(X) - 1 here, so an + # off-by-one while recovering first occurrences indexes out of bounds. + X = ["a", "a", "a", "b"] + expected_order, expected_alphabet = reference_order(X) + exists_order, exists_alphabet = order(X, True) + assert_array_equal(expected_order, exists_order) + assert_array_equal(expected_alphabet, exists_alphabet) + + def test_every_alphabet_index_is_produced(self): + # Guards against partially filled scratch buffers: a gap would leave + # uninitialised memory in the result instead of a valid index. + X = ["c", "a", "b", "a", "c", "d", "b"] + exists_order, exists_alphabet = order(X, True) + assert_array_equal(np.arange(len(exists_alphabet)), np.unique(exists_order)) + + def test_random_sequences_match_reference(self): + rng = np.random.default_rng(20260914) + letters = np.array(["T", "A", "C", "G"]) + for _ in range(200): + power = int(rng.integers(1, letters.size + 1)) + length = int(rng.choice([1, 2, 3, 7, 64, 500])) + pool = rng.choice(letters, size=power, replace=False) + # Skewed weights so some symbols stay rare or appear only once. + weights = rng.dirichlet(np.ones(power) * 0.4) + X = rng.choice(pool, size=length, p=weights) + + expected_order, expected_alphabet = reference_order(X) + for candidate in (X, as_reverse_stable(X)): + exists_order, exists_alphabet = order(candidate, True) + assert_array_equal(expected_order, exists_order) + assert_array_equal(expected_alphabet, exists_alphabet) + assert_array_equal( + np.asarray(X), np.asarray(exists_alphabet)[exists_order] + ) + + def test_random_integer_sequences_match_reference(self): + rng = np.random.default_rng(11) + for _ in range(100): + length = int(rng.choice([1, 5, 50, 400])) + high = int(rng.choice([1, 2, 10, 1000])) + X = rng.integers(0, high + 1, size=length) + + expected_order, expected_alphabet = reference_order(X) + for candidate in (X, as_reverse_stable(X)): + exists_order, exists_alphabet = order(candidate, True) + assert_array_equal(expected_order, exists_order) + assert_array_equal(expected_alphabet, exists_alphabet) diff --git a/tests/test_partials_alphabet.py b/tests/test_partials_alphabet.py index fe48913e..c3069e7e 100644 --- a/tests/test_partials_alphabet.py +++ b/tests/test_partials_alphabet.py @@ -145,6 +145,8 @@ def test_returns_plain_ndarray(self): def test_signature_has_input_and_return_annotations(self): signature = inspect.signature(alphabet) assert signature.parameters["X"].annotation is not inspect.Parameter.empty + assert signature.parameters["axis"].default is None + assert signature.parameters["axis"].kind is inspect.Parameter.KEYWORD_ONLY assert signature.return_annotation is not inspect.Signature.empty # ------------------------------------------------------------------------- diff --git a/tests/test_partials_axis_alphabet_order.py b/tests/test_partials_axis_alphabet_order.py new file mode 100644 index 00000000..e110e51e --- /dev/null +++ b/tests/test_partials_axis_alphabet_order.py @@ -0,0 +1,167 @@ +import importlib + +import numpy as np +import numpy.ma as ma +import pytest +from numpy.ma.testutils import assert_equal +from numpy.testing import assert_array_equal + +from foapy.core import alphabet as core_alphabet +from foapy.core import order as core_order +from foapy.exceptions import Not1DArrayException +from foapy.partials import alphabet, order + +try: + from numpy.exceptions import AxisError +except ImportError: # NumPy < 1.25 + from numpy import AxisError + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +def test_1d_calls_do_not_use_multidimensional_factorizer(monkeypatch, axis): + alphabet_module = importlib.import_module("foapy.partials._alphabet") + order_module = importlib.import_module("foapy.partials._order") + + def fail(*args, **kwargs): + pytest.fail("one-dimensional input entered multidimensional factorization") + + monkeypatch.setattr(alphabet_module, "_stable_partial_factorize", fail) + monkeypatch.setattr(order_module, "_stable_partial_factorize", fail) + + source = ma.masked_array(["b", "x", "a", "b"], mask=[False, True, False, False]) + assert_array_equal(alphabet(source, axis=axis), ["b", "a"]) + result, result_alphabet = order(source, True, axis=axis) + assert_equal(result, ma.masked_array([0, 0, 1, 0], mask=source.mask)) + assert_array_equal(result_alphabet, ["b", "a"]) + + +@pytest.mark.parametrize("axis", [0, 1]) +def test_plain_2d_input_matches_core(axis): + source = np.array( + [ + ["a", "b", "a", "x"], + ["c", "d", "c", "y"], + ["a", "b", "a", "x"], + ] + ) + + result, result_alphabet = order(source, True, axis=axis) + + assert not np.any(ma.getmaskarray(result)) + assert_array_equal(result.compressed(), core_order(source, axis=axis)) + assert_array_equal(result_alphabet, core_alphabet(source, axis=axis)) + assert_array_equal(alphabet(source, axis=axis), result_alphabet) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_whole_slice_gaps_for_3d_inputs(axis): + dense_alphabet = np.take(np.arange(12).reshape(2, 2, 3), [0, 1], axis=axis) + source_data = np.take(dense_alphabet, [0, 0, 1, 0], axis=axis) + source_mask = np.zeros(source_data.shape, dtype=bool) + gap_index = [slice(None)] * source_data.ndim + gap_index[axis] = 1 + source_mask[tuple(gap_index)] = True + source = ma.masked_array(source_data, mask=source_mask) + + result, result_alphabet = order(source, True, axis=axis) + + assert_equal(result, ma.masked_array([0, 0, 1, 0], mask=[0, 1, 0, 0])) + assert_array_equal(result_alphabet, dense_alphabet) + assert_array_equal(alphabet(source, axis=axis), dense_alphabet) + + +def test_masked_first_slice_does_not_control_first_appearance(): + source = ma.masked_array( + [[1, 1], [3, 3], [1, 1], [2, 2]], + mask=[[1, 1], [0, 0], [0, 0], [0, 0]], + ) + + result, result_alphabet = order(source, True, axis=0) + + assert_equal(result, ma.masked_array([0, 0, 1, 2], mask=[1, 0, 0, 0])) + assert_array_equal(result_alphabet, [[3, 3], [1, 1], [2, 2]]) + + +def test_partial_reconstruction_restores_observed_slices_and_gap_mask(): + source = ma.masked_array( + [[1, 9, 3, 1], [2, 9, 4, 2]], + mask=[[0, 1, 0, 0], [0, 1, 0, 0]], + ) + axis = 1 + + result, result_alphabet = order(source, True, axis=axis) + restored_data = np.take(result_alphabet, result.filled(0), axis=axis) + restored_mask = np.broadcast_to( + ma.getmaskarray(result).reshape(1, result.size), source.shape + ) + restored = ma.masked_array(restored_data, mask=restored_mask) + + assert ma.allequal(restored, source) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_negative_axis_matches_positive_axis(axis): + source_data = np.take(np.arange(12).reshape(2, 2, 3), [0, 1, 0], axis=axis) + source = ma.masked_array(source_data, mask=False) + negative_axis = axis - source.ndim + + positive_order, positive_alphabet = order(source, True, axis=axis) + negative_order, negative_alphabet = order(source, True, axis=negative_axis) + + assert_equal(negative_order, positive_order) + assert_array_equal(negative_alphabet, positive_alphabet) + + +def test_fully_masked_input_returns_axis_preserving_empty_alphabet(): + source = ma.masked_array(np.arange(12).reshape(2, 3, 2), mask=True) + + result, result_alphabet = order(source, True, axis=1) + + assert result.shape == (3,) + assert np.all(ma.getmaskarray(result)) + assert result_alphabet.shape == (2, 0, 2) + assert not isinstance(result_alphabet, ma.MaskedArray) + + +def test_empty_sequence_axis_returns_empty_results(): + source = ma.masked_array(np.empty((2, 0, 3)), mask=False) + + result, result_alphabet = order(source, True, axis=1) + + assert result.shape == (0,) + assert result_alphabet.shape == (2, 0, 3) + + +def test_empty_orthogonal_dimensions_describe_present_empty_slices(): + source = ma.masked_array(np.empty((3, 0)), mask=False) + + result, result_alphabet = order(source, True, axis=0) + + assert_equal(result, [0, 0, 0]) + assert result_alphabet.shape == (1, 0) + + +@pytest.mark.parametrize("function", [alphabet, order]) +def test_mixed_mask_inside_slice_is_rejected(function): + source = ma.masked_array([[1, 2], [3, 4]], mask=[[0, 1], [0, 0]]) + + with pytest.raises(ValueError, match="wholly masked or wholly unmasked"): + function(source, axis=0) + + +@pytest.mark.parametrize("function", [alphabet, order]) +def test_multidimensional_input_without_axis_is_rejected(function): + with pytest.raises(Not1DArrayException): + function(ma.masked_array([[1, 2], [3, 4]], mask=False)) + + +@pytest.mark.parametrize("function", [alphabet, order]) +def test_scalar_input_is_rejected(function): + with pytest.raises(Not1DArrayException): + function(ma.masked_array(1), axis=0) + + +@pytest.mark.parametrize("function", [alphabet, order]) +def test_invalid_axis_raises_numpy_axis_error(function): + with pytest.raises(AxisError): + function(ma.masked_array([[1, 2], [3, 4]], mask=False), axis=2) diff --git a/tests/test_partials_axis_intervals_chain.py b/tests/test_partials_axis_intervals_chain.py new file mode 100644 index 00000000..e8ba876a --- /dev/null +++ b/tests/test_partials_axis_intervals_chain.py @@ -0,0 +1,201 @@ +import importlib + +import numpy as np +import numpy.ma as ma +import pytest +from numpy.ma.testutils import assert_equal +from numpy.testing import assert_array_equal + +from foapy import binding, chain_mode +from foapy.core import intervals_chain as core_intervals_chain +from foapy.exceptions import Not1DArrayException +from foapy.partials import intervals_chain + +try: + from numpy.exceptions import AxisError +except ImportError: # NumPy < 1.25 + from numpy import AxisError + + +EXPECTED_DATA = { + (binding.start, chain_mode.boundary): [1, 0, 3, 3], + (binding.start, chain_mode.cycle): [1, 0, 4, 3], + (binding.end, chain_mode.boundary): [3, 0, 2, 1], + (binding.end, chain_mode.cycle): [3, 0, 4, 1], +} +EXPECTED_MASK = [False, True, False, False] + + +def _whole_slice_gap_source(axis): + data = np.array([[1, 2], [9, 9], [3, 4], [1, 2]]) + mask = np.array([[False, False], [True, True], [False, False], [False, False]]) + if axis == 1: + data = data.T + mask = mask.T + return ma.masked_array(data, mask=mask) + + +@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("binding_value,chain_mode_value", EXPECTED_DATA) +def test_whole_slice_gaps_count_in_all_modes(axis, binding_value, chain_mode_value): + result = intervals_chain( + _whole_slice_gap_source(axis), + binding_value, + chain_mode_value, + axis=axis, + ) + + assert_equal( + result, + ma.masked_array( + EXPECTED_DATA[(binding_value, chain_mode_value)], + mask=EXPECTED_MASK, + ), + ) + assert result.dtype == np.dtype(np.intp) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize("chain_mode_value", [chain_mode.boundary, chain_mode.cycle]) +def test_plain_multidimensional_input_matches_core( + axis, binding_value, chain_mode_value +): + source_alphabet = np.take(np.arange(12).reshape(2, 2, 3), [0, 1], axis=axis) + source = np.take(source_alphabet, [0, 1, 0, 1, 0], axis=axis) + + result = intervals_chain(source, binding_value, chain_mode_value, axis=axis) + expected = core_intervals_chain(source, binding_value, chain_mode_value, axis=axis) + + assert not np.any(ma.getmaskarray(result)) + assert_array_equal(result.data, expected) + + +@pytest.mark.parametrize("axis", [0, 1]) +def test_fully_unmasked_multidimensional_input_matches_core(axis): + rows = np.array([[1, 2], [3, 4], [1, 2], [5, 6]]) + source_data = rows if axis == 0 else rows.T + source = ma.masked_array(source_data, mask=False) + + result = intervals_chain(source, binding.end, chain_mode.cycle, axis=axis) + expected = core_intervals_chain( + source_data, binding.end, chain_mode.cycle, axis=axis + ) + + assert not np.any(ma.getmaskarray(result)) + assert_array_equal(result.data, expected) + + +def test_mixed_mask_inside_slice_is_rejected(): + source = ma.masked_array([[1, 2], [3, 4]], mask=[[False, True], [False, False]]) + + with pytest.raises(ValueError, match="wholly masked or wholly unmasked"): + intervals_chain(source, binding.start, chain_mode.boundary, axis=0) + + +def test_fully_masked_axis_returns_fully_masked_chain(): + source = ma.masked_array(np.arange(12).reshape(2, 3, 2), mask=True) + + result = intervals_chain(source, binding.start, chain_mode.boundary, axis=1) + + assert result.shape == (3,) + assert result.dtype == np.dtype(np.intp) + assert np.all(ma.getmaskarray(result)) + + +def test_empty_sequence_axis_returns_empty_masked_chain(): + source = ma.masked_array(np.empty((2, 0, 3)), mask=False) + + result = intervals_chain(source, binding.start, chain_mode.boundary, axis=1) + + assert result.shape == (0,) + assert result.dtype == np.dtype(np.intp) + + +def test_structurally_empty_slices_are_present_and_equal(): + source = ma.masked_array(np.empty((3, 0)), mask=False) + + result = intervals_chain(source, binding.start, chain_mode.boundary, axis=0) + + assert_equal(result, [1, 1, 1]) + + +def test_negative_axis_matches_positive_axis(): + source = _whole_slice_gap_source(axis=1) + + positive = intervals_chain(source, binding.end, chain_mode.cycle, axis=1) + negative = intervals_chain(source, binding.end, chain_mode.cycle, axis=-1) + + assert_equal(negative, positive) + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +def test_1d_calls_use_legacy_direct_path(monkeypatch, axis): + module = importlib.import_module("foapy.partials._intervals_chain") + + def fail(*args, **kwargs): + pytest.fail("one-dimensional input entered axis factorization") + + monkeypatch.setattr(module, "partial_order", fail) + source = ma.masked_array(["A", "x", "A"], mask=[False, True, False]) + + result = intervals_chain(source, binding.start, chain_mode.boundary, axis=axis) + + assert_equal(result, ma.masked_array([1, 0, 2], mask=[False, True, False])) + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize("chain_mode_value", [chain_mode.boundary, chain_mode.cycle]) +def test_plain_1d_calls_use_core_kernel( + monkeypatch, axis, binding_value, chain_mode_value +): + module = importlib.import_module("foapy.partials._intervals_chain") + source = np.array([2, 1, 2, 3, 2]) + expected = core_intervals_chain(source, binding_value, chain_mode_value) + + def fail(*args, **kwargs): + pytest.fail("plain one-dimensional input entered the partial kernel") + + monkeypatch.setattr(module, "_intervals_chain_1d", fail) + + result = intervals_chain( + source, + binding_value, + chain_mode_value, + axis=axis, + ) + + assert isinstance(result, ma.MaskedArray) + assert result.dtype == np.dtype(np.intp) + assert not np.any(ma.getmaskarray(result)) + assert_array_equal(result.data, expected) + + +def test_multidimensional_input_without_axis_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_chain( + ma.masked_array([[1, 2], [3, 4]], mask=False), + binding.start, + chain_mode.boundary, + ) + + +def test_scalar_input_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_chain( + ma.masked_array(1), + binding.start, + chain_mode.boundary, + axis=0, + ) + + +def test_out_of_range_axis_raises_numpy_axis_error(): + with pytest.raises(AxisError): + intervals_chain( + ma.masked_array([[1, 2], [3, 4]], mask=False), + binding.start, + chain_mode.boundary, + axis=2, + ) diff --git a/tests/test_partials_axis_intervals_tuple.py b/tests/test_partials_axis_intervals_tuple.py new file mode 100644 index 00000000..49ab1911 --- /dev/null +++ b/tests/test_partials_axis_intervals_tuple.py @@ -0,0 +1,379 @@ +import importlib +import inspect +from typing import Optional, Union + +import numpy as np +import numpy.ma as ma +import pytest +from numpy import ndarray +from numpy.ma.testutils import assert_equal +from numpy.testing import assert_array_equal +from numpy.typing import ArrayLike + +import foapy +import foapy.partials as partials +from foapy import binding, chain_mode, tuple_mode +from foapy.core import intervals_tuple as core_intervals_tuple +from foapy.exceptions import Not1DArrayException +from foapy.partials import intervals_chain, intervals_tuple + +try: + from numpy.exceptions import AxisError +except ImportError: # NumPy < 1.25 + from numpy import AxisError + + +SOURCES = ma.masked_array( + [[0, 9, 1, 0, 9, 2], [9, 0, 0, 1, 0, 9]], + mask=[ + [False, True, False, False, True, False], + [True, False, False, False, False, True], + ], +) + +EXPECTED_ROWS = { + (binding.start, tuple_mode.normal): ma.masked_array( + [[1, 3, 3, 6], [2, 1, 4, 2]], mask=False + ), + (binding.start, tuple_mode.lossy): ma.masked_array( + [[3, 0], [1, 2]], mask=[[False, True], [False, False]] + ), + (binding.start, tuple_mode.redundant): ma.masked_array( + [[1, 3, 3, 6, 4, 3, 1], [2, 1, 4, 2, 3, 2, 0]], + mask=[[False] * 7, [False] * 6 + [True]], + ), + (binding.end, tuple_mode.normal): ma.masked_array( + [[3, 4, 3, 1], [1, 2, 3, 2]], mask=False + ), + (binding.end, tuple_mode.lossy): ma.masked_array( + [[3, 0], [2, 1]], mask=[[False, True], [False, False]] + ), + (binding.end, tuple_mode.redundant): ma.masked_array( + [[1, 3, 4, 3, 6, 3, 1], [2, 3, 2, 1, 4, 2, 0]], + mask=[[False] * 7, [False] * 6 + [True]], + ), +} + + +def _chains(binding_value): + return ma.stack( + [ + intervals_chain( + source, + binding_value, + chain_mode.boundary, + ) + for source in SOURCES + ] + ) + + +def _pack_rows(results): + result_length = max((result.size for result in results), default=0) + packed = ma.masked_all((len(results), result_length), dtype=np.intp) + for index, result in enumerate(results): + packed.data[index, : result.size] = result + packed.mask[index, : result.size] = False + return packed + + +@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_rows_and_columns_are_independent_partial_chains( + axis, binding_value, tuple_mode_value +): + rows = _chains(binding_value) + source = rows if axis == 1 else rows.T + expected_rows = EXPECTED_ROWS[(binding_value, tuple_mode_value)] + expected = expected_rows if axis == 1 else expected_rows.T + + result = intervals_tuple( + source, + binding_value, + tuple_mode_value, + axis=axis, + ) + + assert ma.isMaskedArray(result) + assert_equal(result, expected) + assert result.dtype == np.dtype(np.intp) + + +def _three_dimensional_chains(axis, binding_value): + rows = _chains(binding_value) + lanes = ma.stack([rows[index % 2] for index in range(6)]).reshape(2, 3, 6) + return np.moveaxis(lanes, -1, axis) + + +def _three_dimensional_expected(axis, binding_value, tuple_mode_value): + rows = _chains(binding_value) + lane_results = [ + intervals_tuple(rows[index % 2], binding_value, tuple_mode_value) + for index in range(6) + ] + packed = _pack_rows(lane_results).reshape(2, 3, -1) + return np.moveaxis(packed, -1, axis) + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_3d_axis_replaces_selected_dimension(axis, binding_value, tuple_mode_value): + source = _three_dimensional_chains(axis, binding_value) + expected = _three_dimensional_expected(axis, binding_value, tuple_mode_value) + + result = intervals_tuple( + source, + binding_value, + tuple_mode_value, + axis=axis, + ) + + assert_equal(result, expected) + assert result.shape[axis] == expected.shape[axis] + + +@pytest.mark.parametrize("axis", [0, 1, 2]) +def test_negative_axis_matches_positive_axis(axis): + source = _three_dimensional_chains(axis, binding.start) + + positive = intervals_tuple(source, binding.start, tuple_mode.redundant, axis=axis) + negative = intervals_tuple( + source, + binding.start, + tuple_mode.redundant, + axis=axis - source.ndim, + ) + + assert_equal(negative, positive) + + +def test_uniform_lengths_still_return_masked_array_with_false_mask(): + result = intervals_tuple( + _chains(binding.start), + binding.start, + tuple_mode.normal, + axis=1, + ) + + assert ma.isMaskedArray(result) + assert not np.any(ma.getmaskarray(result)) + + +def test_fully_masked_lane_is_structurally_trailing_masked(): + source = ma.masked_array( + [[0, 0, 0, 0], [1, 2, 2, 4]], + mask=[[True] * 4, [False] * 4], + ) + + result = intervals_tuple( + source, + binding.start, + tuple_mode.normal, + axis=1, + ) + + assert result.shape == (2, 4) + assert np.all(result.mask[0]) + assert_equal(result[1], [1, 2, 2, 4]) + + +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_empty_selected_axis_returns_empty_masked_result(tuple_mode_value): + source = ma.masked_array(np.empty((2, 0, 3)), mask=False) + + result = intervals_tuple( + source, + binding.start, + tuple_mode_value, + axis=1, + ) + + assert ma.isMaskedArray(result) + assert result.shape == (2, 0, 3) + assert result.dtype == np.dtype(np.intp) + + +@pytest.mark.parametrize("shape,axis", [((0, 4), 1), ((4, 0), 0), ((0, 4, 2), 1)]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_absent_lanes_have_zero_length_result_axis(shape, axis, tuple_mode_value): + source = ma.masked_array(np.empty(shape), mask=False) + + result = intervals_tuple( + source, + binding.start, + tuple_mode_value, + axis=axis, + ) + + expected_shape = list(shape) + expected_shape[axis] = 0 + assert ma.isMaskedArray(result) + assert result.shape == tuple(expected_shape) + + +@pytest.mark.parametrize("axis", [None, 0, -1]) +@pytest.mark.parametrize("masked", [False, True]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_1d_calls_use_direct_plain_array_path( + monkeypatch, axis, masked, tuple_mode_value +): + module = importlib.import_module("foapy.partials._intervals_tuple") + plain = np.array([1, 2, 2, 4, 2], dtype=np.intp) + source = ma.masked_array(plain, mask=[False, True, False, False, False]) + if not masked: + source = plain + expected = intervals_tuple(source, binding.start, tuple_mode_value) + + def fail(*args, **kwargs): + pytest.fail("one-dimensional input entered multidimensional packing") + + monkeypatch.setattr(module, "_apply_to_axis_lanes", fail) + result = intervals_tuple( + source, + binding.start, + tuple_mode_value, + axis=axis, + ) + + assert_array_equal(result, expected) + assert isinstance(result, np.ndarray) + assert not ma.isMaskedArray(result) + assert result.dtype == np.dtype(np.intp) + + +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_empty_1d_input_stays_plain(tuple_mode_value): + result = intervals_tuple( + ma.masked_array([], mask=[], dtype=np.intp), + binding.start, + tuple_mode_value, + axis=0, + ) + + assert_array_equal(result, np.array([], dtype=np.intp)) + assert not ma.isMaskedArray(result) + + +DENSE_CHAINS = { + binding.start: np.array([[1, 1, 3, 1], [1, 2, 1, 3]]), + binding.end: np.array([[1, 3, 1, 1], [3, 1, 2, 1]]), +} + + +@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_dense_multidimensional_input_matches_core( + axis, binding_value, tuple_mode_value +): + rows = DENSE_CHAINS[binding_value] + source = rows if axis == 1 else rows.T + + partial_result = intervals_tuple( + ma.masked_array(source, mask=False), + binding_value, + tuple_mode_value, + axis=axis, + ) + core_result = core_intervals_tuple( + source, + binding_value, + tuple_mode_value, + axis=axis, + ) + + assert_equal(partial_result, core_result) + + +def test_multidimensional_input_without_axis_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_tuple( + ma.masked_array([[1, 2], [2, 1]], mask=False), + binding.start, + tuple_mode.normal, + ) + + +def test_scalar_input_is_rejected(): + with pytest.raises(Not1DArrayException): + intervals_tuple( + ma.masked_array(1), + binding.start, + tuple_mode.normal, + axis=0, + ) + + +@pytest.mark.parametrize("axis", [-3, 2]) +def test_invalid_axis_raises_numpy_axis_error(axis): + with pytest.raises(AxisError): + intervals_tuple( + ma.masked_array([[1, 2], [2, 1]], mask=False), + binding.start, + tuple_mode.normal, + axis=axis, + ) + + +@pytest.mark.parametrize("axis", [0, 1]) +@pytest.mark.parametrize("binding_value", [binding.start, binding.end]) +@pytest.mark.parametrize( + "tuple_mode_value", [tuple_mode.normal, tuple_mode.lossy, tuple_mode.redundant] +) +def test_partial_tuple_output_composes_with_existing_distribution( + axis, binding_value, tuple_mode_value +): + rows = _chains(binding_value) + source = rows if axis == 1 else rows.T + tuple_result = intervals_tuple( + source, + binding_value, + tuple_mode_value, + axis=axis, + ) + + result = foapy.intervals_distribution(tuple_result, axis=axis) + lane_results = [ + foapy.intervals_distribution( + intervals_tuple(row, binding_value, tuple_mode_value) + ) + for row in rows + ] + expected_rows = _pack_rows(lane_results) + expected = expected_rows if axis == 1 else expected_rows.T + + assert_equal(result, expected) + + +def test_no_duplicate_partial_distribution_export(): + assert not hasattr(partials, "intervals_distribution") + assert "intervals_distribution" not in partials.__all__ + assert foapy.intervals_distribution is not None + + +def test_signature_and_annotations(): + signature = inspect.signature(intervals_tuple) + + assert list(signature.parameters) == ["chain", "binding", "tuple_mode", "axis"] + assert signature.parameters["chain"].annotation is ArrayLike + assert signature.parameters["binding"].annotation is int + assert signature.parameters["tuple_mode"].annotation is int + assert signature.parameters["axis"].kind is inspect.Parameter.KEYWORD_ONLY + assert signature.parameters["axis"].annotation == Optional[int] + assert signature.return_annotation == Union[ndarray, ma.MaskedArray] diff --git a/tests/test_partials_order.py b/tests/test_partials_order.py index e94c7bdd..34988e5b 100644 --- a/tests/test_partials_order.py +++ b/tests/test_partials_order.py @@ -27,10 +27,13 @@ class TestPartialsOrder(TestCase): def test_signature_and_annotations(self): signature = inspect.signature(order) - assert list(signature.parameters) == ["X", "return_alphabet"] + assert list(signature.parameters) == ["X", "return_alphabet", "axis"] assert signature.parameters["return_alphabet"].default is False + assert signature.parameters["axis"].default is None + assert signature.parameters["axis"].kind is inspect.Parameter.KEYWORD_ONLY assert order.__annotations__["X"] is not None assert order.__annotations__["return_alphabet"] is bool + assert "axis" in order.__annotations__ assert "return" in order.__annotations__ # -------------------------------------------------------------------------