Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

## Current Focus

- Ship `v0.5.0` with square MaxVol for real-valued `Double` matrices backed by
modern Accelerate BLAS/LAPACK.
- Build from `v0.5.0` toward `v1.0.0` with real-valued `Double` algorithms
backed by modern Accelerate BLAS/LAPACK.
- Keep Swift Testing as the package test surface and require reference fixtures
before broadening the algorithm surface.
- Treat public API compatibility as pre-1.0 until RectMaxVol, Float support, and
Expand Down Expand Up @@ -66,22 +66,22 @@

## Swift Package Index

- Swift Package Index submission has been started for the public package.
- Keep SPI readiness checks in the release path.
- Keep `.spi.yml` aligned with DocC targets when package documentation changes.
- Verify `swift package dump-package`, `swift build`, `swift test`, and DocC
generation before submitting to Swift Package Index.
- Submit only after the GitHub repository is public and a SemVer tag exists.
generation before each tagged release.
- Monitor SPI package ingestion and rendered documentation as release tags land.

## GitHub Publication

- Keep the public `gaelic-ghost/MaxVol` repository aligned with SemVer tags.
- Merge focused pull requests into `main` only after serial `swift build`,
`swift test`, repo-maintenance validation, and DocC conversion pass.
- Submit to Swift Package Index after the first public SemVer tag is pushed.

## Before `1.0.0`

- Implement and test RectMaxVol for `Double`.
- Complete RectMaxVol for `Double` with reference-parity fixtures.
- Add `Float` support with the same API shape and reference fixtures.
- Decide whether complex-valued matrices are in scope for `1.0.0` or explicitly
post-1.0.
Expand All @@ -91,5 +91,4 @@
- Add performance benchmarks for allocation count and row-swap throughput.
- Expand DocC with algorithm notes, limitations, and reference-fixture
provenance.
- Submit the tagged public package to Swift Package Index and verify rendered
documentation.
- Verify Swift Package Index renders the tagged `v1.0.0` documentation cleanly.
13 changes: 13 additions & 0 deletions Sources/MaxVol/MaxVol.docc/MaxVol.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ rows:
let coefficients = result.coefficients
```

Call ``rectMaxVol(_:options:)`` when the basis may contain more rows than the
matrix column count:

```swift
let rectangular = try rectMaxVol(matrix, options: RectMaxVolOptions(minRows: 3))
```

## Topics

### Matrix Storage
Expand All @@ -53,6 +60,12 @@ let coefficients = result.coefficients
### MaxVol

- ``maxVol(_:options:)``
- ``rectMaxVol(_:options:)``
- ``MaxVolOptions``
- ``RectMaxVolOptions``
- ``MaxVolResult``
- ``MaxVolError``

### Algorithm Notes

- <doc:ToleranceAndConvergence>
70 changes: 70 additions & 0 deletions Sources/MaxVol/MaxVol.docc/ToleranceAndConvergence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Tolerance and Convergence

Use tolerance, iteration limits, and row-count bounds to decide how much work
MaxVol should do before returning a basis.

## Overview

MaxVol returns a ``MaxVolResult`` instead of only returning selected row indices
so callers can inspect the stopping behavior. The ``MaxVolResult/converged``
flag reports whether the configured stopping criterion was satisfied, while
``MaxVolResult/iterations`` reports how many update steps ran after the initial
basis was chosen.

For an input matrix `A`, both square and rectangular results use the same
reconstruction shape:

```swift
// A ~= C * A[selectedRows, :]
let selectedRows = result.selectedRows
let coefficients = result.coefficients
```

## Square MaxVol

``maxVol(_:options:)`` selects exactly one row per matrix column. The square
algorithm starts from an LU-pivoted basis, computes expansion coefficients, and
swaps rows while any coefficient magnitude is larger than
``MaxVolOptions/tolerance``.

The square tolerance must be at least `1.0`. Values closer to `1.0` usually do
more row swaps and produce a stronger local maximum-volume basis. Larger values
allow earlier stopping.

If ``MaxVolOptions/maxIterations`` is reached first, the result is still
validated and reconstructs through its current selected rows, but
``MaxVolResult/converged`` is `false`.

## RectMaxVol

``rectMaxVol(_:options:)`` starts from square MaxVol, then appends extra rows.
The rectangular stopping test uses coefficient row norms instead of individual
coefficient magnitudes: unselected rows are appended while their coefficient row
norm exceeds ``RectMaxVolOptions/tolerance``.

``RectMaxVolOptions/minRows`` can force extra rows even when the tolerance is
already satisfied. ``RectMaxVolOptions/maxRows`` can stop the append loop before
the tolerance is satisfied; in that case ``MaxVolResult/converged`` is `false`.
When every row is selected, the result is considered converged because there are
no unselected coefficient rows left to violate the tolerance.

## Choosing Options

Use the default options first for general row-basis selection. Tighten square
``MaxVolOptions/tolerance`` or raise ``MaxVolOptions/maxIterations`` when a
stronger square basis matters. For rectangular selection, set
``RectMaxVolOptions/minRows`` when a downstream approximation requires a minimum
basis size, and set ``RectMaxVolOptions/maxRows`` when runtime or storage must
be bounded.

## Topics

### Related APIs

- ``maxVol(_:options:)``
- ``rectMaxVol(_:options:)``
- ``MaxVolOptions``
- ``RectMaxVolOptions``
- ``MaxVolResult``
- ``MaxVolResult/converged``
- ``MaxVolResult/iterations``
15 changes: 12 additions & 3 deletions Sources/MaxVol/MaxVolError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ public enum MaxVolError: Error, Equatable, Sendable {
/// A requested selection count is incompatible with the available rows.
case invalidSelectionCount(requested: Int, availableRows: Int)

/// Rectangular row bounds are incompatible with the input matrix.
case invalidRowSelectionBounds(
minRows: Int,
maxRows: Int,
requiredRows: Int,
availableRows: Int
)

/// A selected row index is outside the input matrix bounds.
case invalidSelectedRowIndex(row: Int, availableRows: Int)

Expand All @@ -30,7 +38,7 @@ public enum MaxVolError: Error, Equatable, Sendable {
/// The result coefficient matrix does not match the selected row count.
case coefficientColumnMismatch(selectedRows: Int, coefficientColumns: Int)

/// The convergence tolerance is not finite or is less than `1.0`.
/// The convergence tolerance is outside the valid range for the requested algorithm.
case invalidTolerance(Double)

/// The maximum iteration limit is negative.
Expand All @@ -44,7 +52,6 @@ public enum MaxVolError: Error, Equatable, Sendable {

/// An Accelerate LAPACK routine reported an unexpected nonzero `info` value.
case lapackFailure(routine: String, info: Int)

}

extension MaxVolError: CustomStringConvertible {
Expand All @@ -64,14 +71,16 @@ extension MaxVolError: CustomStringConvertible {
"MaxVol requires a tall or square input matrix with rows >= columns, but received rows: \(rows), columns: \(columns)."
case let .invalidSelectionCount(requested, availableRows):
"MaxVol cannot select \(requested) rows from a matrix with \(availableRows) available rows."
case let .invalidRowSelectionBounds(minRows, maxRows, requiredRows, availableRows):
"RectMaxVol row bounds are invalid: minRows \(minRows), maxRows \(maxRows), required rows at least \(requiredRows), available rows \(availableRows)."
case let .invalidSelectedRowIndex(row, availableRows):
"MaxVol selected row index \(row) is out of bounds for a matrix with \(availableRows) rows."
case let .duplicateSelectedRow(row):
"MaxVol selected row index \(row) appears more than once, but selected rows must be unique."
case let .coefficientColumnMismatch(selectedRows, coefficientColumns):
"MaxVol result has \(selectedRows) selected rows but \(coefficientColumns) coefficient columns."
case let .invalidTolerance(tolerance):
"MaxVol tolerance must be finite and at least 1.0, but received \(tolerance)."
"MaxVol tolerance is outside the valid range for the requested algorithm, but received \(tolerance)."
case let .invalidIterationLimit(limit):
"MaxVol maximum iteration limit must be nonnegative, but received \(limit)."
case let .invalidIterationCount(iterations):
Expand Down
1 change: 0 additions & 1 deletion Sources/MaxVol/MaxVolOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ public struct MaxVolOptions: Equatable, Hashable, Sendable {
guard tolerance.isFinite, tolerance >= 1 else {
throw MaxVolError.invalidTolerance(tolerance)
}

guard maxIterations >= 0 else {
throw MaxVolError.invalidIterationLimit(maxIterations)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/MaxVol/MaxVolResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ public struct MaxVolResult<Scalar: Sendable>: Sendable {
/// `A ~= coefficients * A[selectedRows, :]`.
public let coefficients: DenseColumnMajorMatrix<Scalar>

/// The number of row-replacement iterations performed after the initial basis.
/// The number of algorithm iterations performed after the initial basis.
public let iterations: Int

/// Whether the coefficient matrix satisfied the configured tolerance.
/// Whether the coefficient matrix satisfied the configured stopping criterion.
public let converged: Bool

/// Creates a validated result value.
Expand Down
163 changes: 163 additions & 0 deletions Sources/MaxVol/RectMaxVol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import Accelerate

private typealias LAPACKInt = __LAPACK_int

/// Selects a high-volume rectangular row basis from a tall dense `Double` matrix.
///
/// RectMaxVol starts with ``maxVol(_:options:)`` and appends rows until every
/// remaining unselected coefficient row satisfies ``RectMaxVolOptions/tolerance``
/// or the configured row bounds stop the append loop.
public func rectMaxVol(
_ matrix: DenseColumnMajorMatrix<Double>,
options: RectMaxVolOptions = RectMaxVolOptions()
) throws -> MaxVolResult<Double> {
let input = try matrix.validatedTallMatrix()
let options = try options.resolved(for: input)
let initial = try maxVol(
input,
options: MaxVolOptions(maxIterations: options.startMaxVolIterations)
)
var selectedRows = initial.selectedRows
var coefficients = initial.coefficients
var iterations = 0

while true {
let candidate = maximumUnselectedRowNormSquared(
in: coefficients,
selectedRows: selectedRows
)
let toleranceNeedsAppend = candidate.value > options.toleranceSquared
&& selectedRows.count < options.maxRows
let minimumNeedsAppend = selectedRows.count < options.minRows

guard toleranceNeedsAppend || minimumNeedsAppend else {
let output = try coefficientsWithIdentityRows(
coefficients,
selectedRows: selectedRows
)
return try MaxVolResult(
selectedRows: selectedRows,
coefficients: output,
iterations: iterations,
converged: candidate.value <= options.toleranceSquared || selectedRows.count == input.rows
)
}
guard let candidateRow = candidate.row, selectedRows.count < options.maxRows else {
let output = try coefficientsWithIdentityRows(
coefficients,
selectedRows: selectedRows
)
return try MaxVolResult(
selectedRows: selectedRows,
coefficients: output,
iterations: iterations,
converged: false
)
}

coefficients = try appendRectangularBasisRow(
candidateRow,
to: coefficients
)
selectedRows.append(candidateRow)
iterations += 1
}
}

private struct RowNormCandidate {
let row: Int?
let value: Double
}

private func maximumUnselectedRowNormSquared(
in coefficients: DenseColumnMajorMatrix<Double>,
selectedRows: [Int]
) -> RowNormCandidate {
let selected = Set(selectedRows)
var candidate = RowNormCandidate(row: nil, value: 0)

for row in 0..<coefficients.rows where !selected.contains(row) {
let value = (0..<coefficients.columns).reduce(0) { total, column in
let coefficient = coefficients[row: row, column: column]
return total + coefficient * coefficient
}

if candidate.row == nil || value > candidate.value {
candidate = RowNormCandidate(row: row, value: value)
}
}

return candidate
}

private func appendRectangularBasisRow(
_ candidateRow: Int,
to coefficients: DenseColumnMajorMatrix<Double>
) throws -> DenseColumnMajorMatrix<Double> {
let candidateCoefficients = try coefficients.row(candidateRow)
let projection = (0..<coefficients.rows).map { row in
(0..<coefficients.columns).reduce(0) { total, column in
total + coefficients[row: row, column: column] * candidateCoefficients[column]
}
}
let scale = 1 / (1 + projection[candidateRow])
let appendedColumn = projection.map { scale * $0 }
var updatedValues = coefficients.values
let rowCount = try lapackInt(coefficients.rows)
let columnCount = try lapackInt(coefficients.columns)
let increment = LAPACKInt(1)
let leadingDimension = try lapackInt(coefficients.leadingDimension)

projection.withUnsafeBufferPointer { projectionBuffer -> Void in
candidateCoefficients.withUnsafeBufferPointer { coefficientBuffer -> Void in
updatedValues.withUnsafeMutableBufferPointer { updatedBuffer -> Void in
cblas_dger(
CblasColMajor,
rowCount,
columnCount,
-scale,
projectionBuffer.baseAddress,
increment,
coefficientBuffer.baseAddress,
increment,
updatedBuffer.baseAddress,
leadingDimension
)
}
}
}

updatedValues.append(contentsOf: appendedColumn)
return try DenseColumnMajorMatrix(
rows: coefficients.rows,
columns: coefficients.columns + 1,
columnMajorValues: updatedValues
)
}

private func coefficientsWithIdentityRows(
_ coefficients: DenseColumnMajorMatrix<Double>,
selectedRows: [Int]
) throws -> DenseColumnMajorMatrix<Double> {
var output = coefficients

for (identityColumn, selectedRow) in selectedRows.enumerated() {
for column in 0..<output.columns {
try output.setValue(
column == identityColumn ? 1 : 0,
row: selectedRow,
column: column
)
}
}

return output
}

private func lapackInt(_ value: Int) throws -> LAPACKInt {
guard value <= Int(LAPACKInt.max) else {
throw MaxVolError.invalidDimensions(rows: value, columns: value)
}

return LAPACKInt(value)
}
Loading
Loading