From 11a7dc4be1809c538ebe0961056a9d4ec8df7f6a Mon Sep 17 00:00:00 2001 From: Gale W Date: Tue, 30 Jun 2026 23:55:47 -0400 Subject: [PATCH 1/7] docs: sync swift package guidance --- .../workflows/validate-repo-maintenance.yml | 26 + .swiftformat | 92 ++++ .swiftlint.yml | 20 + AGENTS.md | 39 ++ scripts/repo-maintenance/config/profile.env | 3 + scripts/repo-maintenance/config/release.env | 16 + .../repo-maintenance/config/validation.env | 2 + .../repo-maintenance/hooks/pre-commit.sample | 35 ++ scripts/repo-maintenance/lib/common.sh | 202 +++++++ scripts/repo-maintenance/release.sh | 496 ++++++++++++++++++ .../repo-maintenance/release/10-preflight.sh | 35 ++ .../release/20-tag-release.sh | 23 + .../release/30-push-release.sh | 19 + .../release/40-github-release.sh | 35 ++ scripts/repo-maintenance/sync-shared.sh | 12 + scripts/repo-maintenance/syncing/README.md | 31 ++ scripts/repo-maintenance/validate-all.sh | 13 + .../validations/10-toolkit-layout.sh | 16 + .../validations/20-agents-guidance.sh | 23 + .../validations/30-ci-wrapper.sh | 15 + 20 files changed, 1153 insertions(+) create mode 100644 .github/workflows/validate-repo-maintenance.yml create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100644 scripts/repo-maintenance/config/profile.env create mode 100644 scripts/repo-maintenance/config/release.env create mode 100644 scripts/repo-maintenance/config/validation.env create mode 100755 scripts/repo-maintenance/hooks/pre-commit.sample create mode 100755 scripts/repo-maintenance/lib/common.sh create mode 100755 scripts/repo-maintenance/release.sh create mode 100755 scripts/repo-maintenance/release/10-preflight.sh create mode 100755 scripts/repo-maintenance/release/20-tag-release.sh create mode 100755 scripts/repo-maintenance/release/30-push-release.sh create mode 100755 scripts/repo-maintenance/release/40-github-release.sh create mode 100755 scripts/repo-maintenance/sync-shared.sh create mode 100644 scripts/repo-maintenance/syncing/README.md create mode 100755 scripts/repo-maintenance/validate-all.sh create mode 100755 scripts/repo-maintenance/validations/10-toolkit-layout.sh create mode 100755 scripts/repo-maintenance/validations/20-agents-guidance.sh create mode 100755 scripts/repo-maintenance/validations/30-ci-wrapper.sh diff --git a/.github/workflows/validate-repo-maintenance.yml b/.github/workflows/validate-repo-maintenance.yml new file mode 100644 index 0000000..5e05590 --- /dev/null +++ b/.github/workflows/validate-repo-maintenance.yml @@ -0,0 +1,26 @@ +name: Validate Repo Maintenance + +# Branch protection should require the Actions check context `validate`. +# GitHub exposes the job check run by this job name, not by the workflow title. + +on: + pull_request: + push: + branches: + - main + +jobs: + validate: + name: validate + runs-on: macos-26 + steps: + # This is a validated floor, not a ceiling; update to newer stable official versions when validated. + - uses: actions/checkout@v6.0.2 + - name: Report selected Xcode + run: xcode-select --print-path + - name: Report Swift toolchain + run: xcrun swift --version + - name: Install Swift repo-maintenance tools + run: brew install swiftformat swiftlint + - name: Run repo-maintenance validation + run: bash scripts/repo-maintenance/validate-all.sh diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..62ecf88 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,92 @@ +# Exported from Gale's SwiftFormat for Xcode settings and curated for repo use. +# This file is the repository source of truth for formatting. If the host app +# configuration changes later, re-export from the shared SwiftFormat settings +# and review the diff before importing it back into the app. + +--rules andOperator,anyObjectProtocol,applicationMain,assertionFailures,blankLineAfterImports,blankLinesAfterGuardStatements,blankLinesAroundMark,blankLinesAtEndOfScope,blankLinesAtStartOfScope,blankLinesBetweenChainedFunctions,blankLinesBetweenImports,blankLinesBetweenScopes,braces,conditionalAssignment,consecutiveBlankLines,consecutiveSpaces,consistentSwitchCaseSpacing,docComments,docCommentsBeforeModifiers,duplicateImports,elseOnSameLine,emptyBraces,emptyExtensions,enumNamespaces,environmentEntry,extensionAccessControl,fileMacro,genericExtensions,headerFileName,hoistAwait,hoistPatternLet,hoistTry,indent,initCoderUnavailable,isEmpty,leadingDelimiters,linebreakAtEndOfFile,linebreaks,modifierOrder,noForceTryInTests,noForceUnwrapInTests,noGuardInTests,numberFormatting,opaqueGenericParameters,organizeDeclarations,preferFinalClasses,privateStateVariables,redundantAsync,redundantBackticks,redundantBreak,redundantClosure,redundantEquatable,redundantExtensionACL,redundantFileprivate,redundantGet,redundantInit,redundantInternal,redundantLet,redundantLetError,redundantMemberwiseInit,redundantNilInit,redundantObjc,redundantOptionalBinding,redundantParens,redundantPattern,redundantPublic,redundantRawValues,redundantReturn,redundantSelf,redundantSendable,redundantStaticSelf,redundantSwiftTestingSuite,redundantThrows,redundantType,redundantTypedThrows,redundantVariable,redundantViewBuilder,semicolons,simplifyGenericConstraints,sortDeclarations,sortImports,sortTypealiases,spaceAroundBraces,spaceAroundBrackets,spaceAroundComments,spaceAroundGenerics,spaceAroundOperators,spaceAroundParens,spaceInsideBrackets,spaceInsideComments,spaceInsideGenerics,spaceInsideParens,strongOutlets,strongifiedSelf,swiftTestingTestCaseNames,todos,trailingClosures,trailingCommas,trailingSpace,typeSugar,validateTestCases,void,wrap,wrapArguments,wrapAttributes,wrapLoopBodies,wrapMultilineFunctionChains,wrapSingleLineComments,yodaConditions + +--acronyms ID,URL,UUID +--allow-partial-wrapping true +--anonymous-for-each convert +--asset-literals visual-width +--binary-grouping 4,8 +--line-between-guards false +--category-mark "MARK: %c" +--class-threshold 0 +--closing-paren balanced +--closure-void remove +--complex-attributes preserve +--computed-var-attributes preserve +--conditional-assignment after-property +--date-format system +--decimal-grouping 3,6 +--doc-comments before-declarations +--else-position same-line +--empty-braces no-space +--enum-namespaces always +--equatable-macro none +--exponent-case lowercase +--extension-acl on-extension +--file-macro "#file" +--func-attributes preserve +--group-blank-lines true +--guard-else auto +--header ignore +--hex-grouping 4,8 +--hex-literal-case uppercase +--ifdef outdent +--import-grouping alpha,access-control +--indent 4 +--indent-case true +--indent-strings false +--inferred-types always +--init-coder-nil false +--line-after-marks true +--linebreaks lf +--mark-categories false +--mark-class-threshold 40 +--mark-enum-threshold 40 +--mark-extension-threshold 40 +--mark-struct-threshold 40 +--max-width none +--operator-func spaced +--organization-mode type +--organize-types actor,class,enum,struct +--pattern-let hoist +--prefer-synthesized-init-for-internal-structs never +--property-types infer-locals-only +--ranges no-space +--redundant-async tests-only +--redundant-throws tests-only +--self remove +--semicolons inline-only +--short-optionals preserve-struct-inits +--smart-tabs enabled +--some-any true +--sort-swiftui-properties alphabetize +--stored-var-attributes preserve +--struct-threshold 40 +--strip-unused-args always +--suite-name-format standard-identifiers +--test-case-name-format raw-identifiers +--timezone system +--trailing-commas always +--trim-whitespace always +--type-attributes preserve +--type-blank-lines remove +--type-body-marks preserve +--type-delimiter space-after +--class-threshold 40 +--enum-threshold 40 +--extension-threshold 40 +--void-type Void +--wrap-arguments preserve +--wrap-collections preserve +--wrap-conditions preserve +--wrap-effects preserve +--wrap-return-type preserve +--wrap-string-interpolation default +--wrap-ternary default +--wrap-type-aliases preserve +--xcode-indentation disabled +--yoda-swap always diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..715bb5c --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,20 @@ +# Keep SwiftLint focused on non-formatting checks. +# SwiftFormat owns visual shape in this repository. + +excluded: + - .build + - .local + +only_rules: + - duplicate_imports + - empty_count + - fatal_error_message + - force_try + - force_unwrapping + - unused_import + +force_try: + severity: warning + +force_unwrapping: + severity: warning diff --git a/AGENTS.md b/AGENTS.md index 03223c2..3599ad8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,3 +125,42 @@ - Validate both Debug and Release paths when optimization or packaging differences matter, and treat tagged releases as a cue to verify the Release artifact path before publishing. - Keep `xcodebuild` invocations explicit and reproducible (always pass scheme, destination or SDK, and configuration when relevant). - Prefer deterministic non-interactive CLI usage in automation/CI for both `swift package` and `xcodebuild`. + +## Swift Package Workflow + +- Use `swift build` and `swift test` as the default first-pass validation commands for this package. +- Use `bootstrap-swift-package` when a new Swift package repo still needs to be created from scratch. +- Use `sync-swift-package-guidance` when the repo guidance for this package drifts and needs to be refreshed or merged forward. +- Re-run `sync-swift-package-guidance` after substantial package-workflow or plugin updates so local guidance stays aligned. +- Use `swift-package-build-run-workflow` for manifest, dependency, plugin, resource, Metal-distribution, build, and run work when `Package.swift` is the source of truth. +- Use `swift-package-testing-workflow` for Swift Testing, XCTest holdouts, `.xctestplan`, fixtures, and package test diagnosis. +- Use `scripts/repo-maintenance/validate-all.sh` for local maintainer validation and `scripts/repo-maintenance/sync-shared.sh` for repo-local sync steps. +- Use `scripts/repo-maintenance/release.sh --mode standard --version vX.Y.Z` from a feature branch or worktree only when the task is actually a protected-main release, publish, merge, tag, or release-PR preparation. +- Do not run the standard release workflow from `main`; when a protected-main release is explicitly requested, let it validate, bump versions, tag, push the branch and tag, open the release PR, watch CI, address valid PR comments or record out-of-scope concerns in `ROADMAP.md`, merge to protected `main`, fast-forward local `main`, and clean up stale branches. +- Treat `scripts/repo-maintenance/config/profile.env` as the installed `maintain-project-repo` profile marker, and keep it on the `swift-package` profile for plain package repos. +- Read relevant SwiftPM, Swift, and Apple documentation before proposing package-structure, dependency, manifest, concurrency, or architecture changes. +- Prefer Xcode MCP DocumentationSearch or local Swift docs first for Apple-owned Swift and SDK behavior; use Dash MCP or Dash HTTP next for installed Swift package, SwiftPM, and non-Apple docs; then use official Swift or Apple docs when local docs are insufficient. +- When SwiftPM behavior, manifest syntax, package plugins, resources, products, targets, or dependency rules matter, prefer the Dash.app docset workflow with the `swiftlang/swift-package-manager` docset first; fall back to the canonical `swiftlang/swift-package-manager` GitHub repository only when the local docset is unavailable or insufficient. +- Prefer the simplest correct Swift that is easiest to read and reason about. +- Prefer synthesized and framework-provided behavior over extra wrappers and boilerplate. +- For public Swift APIs, treat streamlined, compact, ergonomic call sites as the only acceptable default; prefer optional parameters with explicit default values over additional methods or overloads when the difference is optional behavior on the same operation. +- When a public function, initializer, or method reaches four or more arguments or parameters, strongly prefer a named typed `struct` request, options, or configuration value so call sites stay readable and future additions do not multiply overloads. +- Prefer enums, enum cases with associated values, and narrow typed values over strings, booleans, sentinel values, or parallel parameters whenever the domain has a closed or meaningful set of choices. +- Keep data flow straight and dependency direction unidirectional. +- Treat `Package.swift` as the source of truth for package structure, targets, products, and dependencies. +- Prefer `swift package` subcommands for structural package edits before manually editing `Package.swift`. +- Edit `Package.swift` intentionally and keep it readable; agents may modify it when package structure, targets, products, or dependencies need to change, and should try to keep package graph updates consolidated in one change when possible. +- Keep `Package.swift` explicit about its package-wide Swift language mode. On current Swift 6-era manifests, prefer `swiftLanguageModes: [.v6]` as the default declaration, treat `swiftLanguageVersions` as a legacy alias used only when an older manifest surface requires it, and keep the supported Swift toolchain window focused on the latest stable minor and previous stable minor. Treat Swift `6.2` as the current minimum floor for trait-enabled manifests, not as a ceiling; use newer stable Swift toolchains when available and validated, and refresh this guidance when the maintained floor or window changes. Do not lower `// swift-tools-version:` below `6.2` without an explicit repo policy and a matching guidance update. +- Keep `swift-configuration` as the default configuration dependency for Swift packages unless the package has a concrete reason to remove it. The preferred manifest shape depends on `https://github.com/apple/swift-configuration` from `1.2.0`, enables the `.defaults`, `Reloading`, `YAML`, and `CommandLineArguments` package traits, and adds the `Configuration` product to the primary target. Add the `PropertyList` trait when the package should parse property-list configuration, and add the `Logging` trait when configuration access should integrate with `SwiftLog.Logger`. +- Keep dependency provenance concise but explicit enough for another contributor to fetch the same package: use package-manager, package-registry, GitHub URL, or other real remote repository requirements, and do not commit machine-local dependency paths such as `/Users/...`, `~/...`, `../...`, local worktrees, or private checkout paths. Avoid branch- or revision-based requirements unless the user explicitly asks for that level of control. +- Treat `Package.resolved` and similar package-manager outputs as generated files; do not hand-edit them. +- Prefer Swift Testing by default unless an external constraint requires XCTest. +- Use `apple-ui-accessibility-workflow` when the package work crosses into SwiftUI accessibility semantics, Apple UI accessibility review, or UIKit/AppKit accessibility bridge behavior. +- Keep package resources under the owning target tree, declare them intentionally with `Resource.process(...)`, `Resource.copy(...)`, `Resource.embedInCode(...)`, and load them through `Bundle.module`. +- Keep test fixtures as test-target resources instead of relying on the working directory. +- Bundle precompiled Metal artifacts such as `.metallib` files as explicit resources when they ship with the package, and prefer `xcode-build-run-workflow` when shader compilation or Apple-managed Metal toolchain behavior matters. +- Prefer normal SwiftPM parallel test execution for ordinary Swift Testing and XCTest runs. Do not serialize regular package tests just because they use Swift, XCTest, async tests, fixtures, or test plans. +- Treat tests that load large local AI or ML models, especially models over 500 million parameters, as heavy system-resource tests. Run those tests sequentially, one at a time, and call `unload_models` on Gale's live TTS service before the heavy run and `reload_models` after it ends, even when the run fails or is interrupted. +- Validate both Debug and Release paths when optimization or packaging differences matter, and treat tagged releases as a cue to verify the Release artifact path before publishing. +- Prefer `xcode-build-run-workflow` or `xcode-testing-workflow` only when package work needs Xcode-managed SDK, toolchain, or test behavior. +- Keep runtime UI accessibility verification and XCUITest follow-through in `xcode-testing-workflow` rather than treating package-side testing as a substitute for live UI verification. diff --git a/scripts/repo-maintenance/config/profile.env b/scripts/repo-maintenance/config/profile.env new file mode 100644 index 0000000..9c0a869 --- /dev/null +++ b/scripts/repo-maintenance/config/profile.env @@ -0,0 +1,3 @@ +# Managed by maintain-project-repo. Do not hand-edit unless you also control the installer contract. +REPO_MAINTENANCE_PROFILE="swift-package" +REPO_MAINTENANCE_PROFILE_DESCRIPTION="Swift Package Manager repo-maintenance profile for library, tool, and package repos." diff --git a/scripts/repo-maintenance/config/release.env b/scripts/repo-maintenance/config/release.env new file mode 100644 index 0000000..5cb1e1c --- /dev/null +++ b/scripts/repo-maintenance/config/release.env @@ -0,0 +1,16 @@ +# Repo-maintenance release defaults. +REPO_MAINTENANCE_DEFAULT_RELEASE_MODE=standard +REPO_MAINTENANCE_RELEASE_BRANCH=main +REPO_MAINTENANCE_REMOTE_CI_MODE=full + +# GitHub can accept branch, tag, PR, check, review, and release mutations before +# those surfaces are immediately readable. These defaults keep release scripts +# explicit about intentional waits instead of failing on transient indexing gaps. +REPO_MAINTENANCE_GH_WAIT_TIMEOUT_SECONDS=120 +REPO_MAINTENANCE_GH_WAIT_POLL_SECONDS=5 + +# Keep full local validation as the default release gate. For repositories whose +# GitHub CI is intentionally heavy, use --remote-ci-mode defer so release.sh +# pauses after branch push, PR creation, and initial check discovery. Codex can +# then use a native thread Timer/Wakeup or heartbeat automation to resume later +# instead of leaving a long-running shell process open just to poll GitHub. diff --git a/scripts/repo-maintenance/config/validation.env b/scripts/repo-maintenance/config/validation.env new file mode 100644 index 0000000..c85b147 --- /dev/null +++ b/scripts/repo-maintenance/config/validation.env @@ -0,0 +1,2 @@ +# Repo-maintenance validation defaults. +REPO_MAINTENANCE_REQUIRE_AGENTS=true diff --git a/scripts/repo-maintenance/hooks/pre-commit.sample b/scripts/repo-maintenance/hooks/pre-commit.sample new file mode 100755 index 0000000..8fc8726 --- /dev/null +++ b/scripts/repo-maintenance/hooks/pre-commit.sample @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(git rev-parse --show-toplevel)" +config_file="$repo_root/.swiftformat" +staged_file_list="$(mktemp "${TMPDIR:-/tmp}/swiftformat-staged.XXXXXX")" +trap 'rm -f "$staged_file_list"' EXIT HUP INT TERM + +if ! command -v swiftformat >/dev/null 2>&1; then + echo "SwiftFormat pre-commit hook could not find the \`swiftformat\` CLI on PATH. Install SwiftFormat before committing, or bypass once with --no-verify if you are unblocking an emergency." >&2 + exit 1 +fi + +if [ ! -f "$config_file" ]; then + echo "SwiftFormat pre-commit hook expected a checked-in config at $config_file, but it was missing. Restore the managed .swiftformat file or refresh maintain-project-repo before committing." >&2 + exit 1 +fi + +cd "$repo_root" +git diff --cached --name-only --diff-filter=ACMR -- '*.swift' > "$staged_file_list" + +if [ ! -s "$staged_file_list" ]; then + exit 0 +fi + +echo "Running SwiftFormat on staged Swift sources..." +swiftformat --config "$config_file" --filelist "$staged_file_list" + +while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + git add -- "$relative_path" +done < "$staged_file_list" + +echo "Verifying staged Swift sources with SwiftFormat lint..." +swiftformat --lint --config "$config_file" --filelist "$staged_file_list" diff --git a/scripts/repo-maintenance/lib/common.sh b/scripts/repo-maintenance/lib/common.sh new file mode 100755 index 0000000..b0afa95 --- /dev/null +++ b/scripts/repo-maintenance/lib/common.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env sh +set -eu + +COMMON_DIR="${REPO_MAINTENANCE_COMMON_DIR:-}" + +if [ -z "$COMMON_DIR" ]; then + COMMON_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fi + +REPO_MAINTENANCE_ROOT=$(CDPATH= cd -- "$COMMON_DIR/.." && pwd) +REPO_ROOT=$(CDPATH= cd -- "$REPO_MAINTENANCE_ROOT/../.." && pwd) +REPO_MAINTENANCE_PROFILE="generic" +REPO_MAINTENANCE_PROFILE_DESCRIPTION="Generic repo-maintenance baseline with no Swift or Xcode specialization." + +log() { + printf '%s\n' "$*" +} + +warn() { + printf 'WARN: %s\n' "$*" >&2 +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +load_env_file() { + env_file="$1" + [ -f "$env_file" ] || return 0 + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a +} + +load_profile_env() { + load_env_file "$REPO_MAINTENANCE_ROOT/config/profile.env" +} + +positive_integer_or_default() { + value="$1" + default_value="$2" + + case "$value" in + ''|*[!0-9]*) + printf '%s\n' "$default_value" + ;; + 0) + printf '%s\n' "$default_value" + ;; + *) + printf '%s\n' "$value" + ;; + esac +} + +is_semver_prerelease_tag() { + tag_name="$1" + case "$tag_name" in + v[0-9]*.[0-9]*.[0-9]*-*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +expected_github_prerelease_value() { + tag_name="$1" + if is_semver_prerelease_tag "$tag_name"; then + printf '%s\n' "true" + else + printf '%s\n' "false" + fi +} + +github_release_create_prerelease_flag() { + tag_name="$1" + if is_semver_prerelease_tag "$tag_name"; then + printf '%s\n' "--prerelease" + fi +} + +verify_github_release_prerelease_metadata() { + tag_name="$1" + expected_value="$(expected_github_prerelease_value "$tag_name")" + + actual_value="$(gh release view "$tag_name" --json isPrerelease --jq .isPrerelease 2>/dev/null || true)" + case "$actual_value" in + true|false) + ;; + *) + die "GitHub release $tag_name exists, but its prerelease metadata was not readable. Confirm gh can read release JSON metadata before rerunning release.sh." + ;; + esac + + [ "$actual_value" = "$expected_value" ] || die "GitHub release $tag_name prerelease metadata mismatch: tag implies isPrerelease=$expected_value but GitHub reports isPrerelease=$actual_value. Update the release metadata or delete and recreate the release before rerunning release.sh." +} + +github_wait_timeout() { + value="$1" + default_timeout="$(positive_integer_or_default "${REPO_MAINTENANCE_GH_WAIT_TIMEOUT_SECONDS:-120}" 120)" + positive_integer_or_default "$value" "$default_timeout" +} + +github_wait_poll_seconds() { + value="$1" + default_poll_seconds="$(positive_integer_or_default "${REPO_MAINTENANCE_GH_WAIT_POLL_SECONDS:-5}" 5)" + positive_integer_or_default "$value" "$default_poll_seconds" +} + +wait_for_remote_branch() { + branch_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_REMOTE_BRANCH_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_REMOTE_BRANCH_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for remote branch origin/$branch_name to become visible." + + while :; do + if git -C "$REPO_ROOT" ls-remote --exit-code --heads origin "$branch_name" >/dev/null 2>&1; then + log "Remote branch origin/$branch_name is visible." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "Remote branch origin/$branch_name was not visible after ${timeout_seconds}s. Confirm the branch push succeeded and that the origin remote is reachable before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +wait_for_remote_tag() { + tag_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_REMOTE_TAG_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_REMOTE_TAG_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for remote tag $tag_name to become visible." + + while :; do + if git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag_name" >/dev/null 2>&1; then + log "Remote tag $tag_name is visible." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "Remote tag $tag_name was not visible after ${timeout_seconds}s. Confirm the tag push succeeded and that GitHub has indexed the tag before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +wait_for_github_release() { + tag_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_GH_RELEASE_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_GH_RELEASE_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for GitHub release $tag_name to become readable." + + while :; do + if gh release view "$tag_name" >/dev/null 2>&1; then + log "GitHub release $tag_name is readable." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "GitHub release $tag_name was not readable after ${timeout_seconds}s. Confirm release creation succeeded and GitHub has indexed the release before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +ensure_git_repo() { + git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "maintain-project-repo must run inside a git worktree rooted at $REPO_ROOT." +} + +run_dispatch_dir() { + dir="$1" + label="$2" + ran_any="false" + + for script in "$dir"/*.sh; do + [ -e "$script" ] || continue + ran_any="true" + log "Running $label step $(basename "$script")" + sh "$script" + done + + if [ "$ran_any" = "false" ]; then + log "No $label steps are currently defined under $dir." + fi +} diff --git a/scripts/repo-maintenance/release.sh b/scripts/repo-maintenance/release.sh new file mode 100755 index 0000000..a9aa4be --- /dev/null +++ b/scripts/repo-maintenance/release.sh @@ -0,0 +1,496 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" +. "$SELF_DIR/lib/common.sh" + +load_profile_env +load_env_file "$SELF_DIR/config/release.env" + +mode="${REPO_MAINTENANCE_DEFAULT_RELEASE_MODE:-standard}" +release_tag="" +skip_validate="false" +skip_gh_release="false" +skip_version_bump="false" +base_branch="${REPO_MAINTENANCE_RELEASE_BRANCH:-main}" +review_comments_addressed="false" +skip_branch_cleanup="false" +dry_run="false" +remote_ci_mode="${REPO_MAINTENANCE_REMOTE_CI_MODE:-full}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --mode) + mode="${2:-}" + shift 2 + ;; + --version) + release_tag="${2:-}" + shift 2 + ;; + --skip-validate) + skip_validate="true" + shift + ;; + --skip-gh-release) + skip_gh_release="true" + shift + ;; + --skip-version-bump) + skip_version_bump="true" + shift + ;; + --base-branch) + base_branch="${2:-}" + shift 2 + ;; + --review-comments-addressed) + review_comments_addressed="true" + shift + ;; + --remote-ci-mode) + remote_ci_mode="${2:-}" + shift 2 + ;; + --skip-branch-cleanup) + skip_branch_cleanup="true" + shift + ;; + --dry-run) + dry_run="true" + shift + ;; + -h|--help) + cat <<'USAGE' +Usage: + release.sh --mode standard --version [--base-branch main] [--skip-validate] [--skip-version-bump] [--skip-gh-release] [--review-comments-addressed] [--remote-ci-mode full|defer] [--skip-branch-cleanup] [--dry-run] + release.sh --mode submodule --version [--skip-validate] [--skip-gh-release] [--dry-run] +USAGE + exit 0 + ;; + *) + die "Unknown release argument: $1" + ;; + esac +done + +[ -n "$release_tag" ] || die "Pass --version vX.Y.Z when running the release workflow." + +export REPO_MAINTENANCE_RELEASE_MODE="$mode" +export RELEASE_TAG="$release_tag" +export REPO_MAINTENANCE_SKIP_GH_RELEASE="$skip_gh_release" +export REPO_MAINTENANCE_DRY_RUN="$dry_run" +export REPO_MAINTENANCE_REMOTE_CI_MODE="$remote_ci_mode" + +ensure_clean_worktree() { + status_output="$(git -C "$REPO_ROOT" status --porcelain)" + [ -z "$status_output" ] || die "Release workflow requires committed changes and a clean worktree before it can continue." +} + +ensure_gh_cli() { + command -v gh >/dev/null 2>&1 || die "Standard release mode requires the GitHub CLI gh so it can create the pull request, watch CI, inspect review comments, merge, and publish the release." +} + +ensure_semver_tag() { + case "$RELEASE_TAG" in + v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) + ;; + *) + die "Release tag must use vX.Y.Z SemVer syntax." + ;; + esac +} + +ensure_remote_ci_mode() { + case "$REPO_MAINTENANCE_REMOTE_CI_MODE" in + full|defer) + ;; + *) + die "Remote CI mode must be either full or defer. Use full to watch GitHub checks in this script, or defer to pause after initial check discovery and continue from a Codex wakeup." + ;; + esac +} + +current_branch() { + git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true +} + +ensure_branch_release_context() { + branch_name="$(current_branch)" + [ -n "$branch_name" ] || die "Standard release mode requires a named feature branch or worktree instead of detached HEAD." + [ "$branch_name" != "$base_branch" ] || die "Standard release mode must run from a release branch or worktree, not protected $base_branch." + printf '%s\n' "$branch_name" +} + +run_version_bump() { + release_version="${RELEASE_TAG#v}" + version_bump_script="$SELF_DIR/version-bump.sh" + head_subject="$(git -C "$REPO_ROOT" log -1 --format=%s 2>/dev/null || true)" + + if [ "$skip_version_bump" = "true" ]; then + log "Skipping repo version bump because --skip-version-bump was requested." + return 0 + fi + + if [ "$head_subject" = "release: bump versions for $RELEASE_TAG" ]; then + log "Version bump commit for $RELEASE_TAG is already at HEAD; continuing the release resume path." + return 0 + fi + + [ -x "$version_bump_script" ] || die "Standard release mode expected an executable repo-specific version bump hook at $version_bump_script. Add that hook so the repo's version surfaces move together, or rerun with --skip-version-bump when this release intentionally has no version-bearing files." + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would run $version_bump_script $release_version with RELEASE_TAG=$RELEASE_TAG." + return 0 + fi + + RELEASE_VERSION="$release_version" "$version_bump_script" "$release_version" + + if [ -z "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + die "Version bump hook completed without changing files. Update $version_bump_script to edit the repo's version surfaces, or rerun with --skip-version-bump if this release intentionally has no version bump." + fi + + git -C "$REPO_ROOT" add -A + git -C "$REPO_ROOT" commit -m "release: bump versions for $RELEASE_TAG" + log "Committed version bump for $RELEASE_TAG." +} + +create_release_tag() { + head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" + tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" + + if [ -n "$tag_sha" ]; then + tag_commit_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$RELEASE_TAG")" + [ "$tag_commit_sha" = "$head_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at HEAD." + log "Tag $RELEASE_TAG already points at HEAD." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would create annotated tag $RELEASE_TAG at HEAD." + return 0 + fi + + git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" + log "Created annotated tag $RELEASE_TAG." +} + +push_release_branch() { + branch_name="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would push branch $branch_name to origin." + return 0 + fi + + git -C "$REPO_ROOT" push -u origin "$branch_name" + log "Pushed branch $branch_name." + wait_for_remote_branch "$branch_name" +} + +push_release_tag() { + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would push tag $RELEASE_TAG to origin." + return 0 + fi + + git -C "$REPO_ROOT" push origin "$RELEASE_TAG" + log "Pushed tag $RELEASE_TAG." + wait_for_remote_tag "$RELEASE_TAG" +} + +create_or_update_pr() { + branch_name="$1" + PR_NUMBER="" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would create or update a release PR from $branch_name into $base_branch." + PR_NUMBER="DRY-RUN" + return 0 + fi + + body_file="$(mktemp "${TMPDIR:-/tmp}/repo-maintenance-release-pr.XXXXXX")" + trap 'rm -f "$body_file"' EXIT INT TERM + + cat >"$body_file" </dev/null + log "Updated existing release PR #$pr_number at $pr_url." + else + gh pr create --base "$base_branch" --head "$branch_name" --title "release: prepare $RELEASE_TAG" --body-file "$body_file" >/dev/null + pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" + [ -n "$pr_number" ] || die "GitHub CLI did not return a release PR number after creating the pull request." + pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" + log "Created release PR #$pr_number at $pr_url." + PR_NUMBER="$pr_number" + return 0 + fi + + PR_NUMBER="$pr_number" +} + +watch_ci() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would watch CI for PR #$pr_number." + return 0 + fi + + log "Watching CI for PR #$pr_number." + if ! gh pr checks "$pr_number" --watch; then + die "CI is not green for PR #$pr_number. Fix the failing checks, push the branch, and rerun release.sh so it can watch CI again." + fi + log "CI is green for PR #$pr_number." +} + +defer_remote_ci_if_requested() { + pr_number="$1" + branch_name="$2" + + [ "$REPO_MAINTENANCE_REMOTE_CI_MODE" = "defer" ] || return 1 + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would defer remote CI after PR #$pr_number reports initial checks." + return 0 + fi + + pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" + log "Remote CI mode is defer, so release.sh is pausing after local validation, branch push, PR creation, and initial check discovery." + log "Release is not complete yet. Let GitHub finish CI for PR #$pr_number, then continue from branch $branch_name with:" + log " bash scripts/repo-maintenance/release.sh --mode standard --version $RELEASE_TAG" + log "Codex should create a same-thread heartbeat automation for this wait when available, then resume by checking $pr_url and rerunning the command above instead of leaving a shell script open to poll GitHub." + log "The heartbeat should also wait for review-bot status contexts such as CodeRabbit to finish before merging; pending review contexts are not a clean merge signal." + return 0 +} + +wait_for_initial_pr_checks() { + pr_number="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_INITIAL_CHECK_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_INITIAL_CHECK_POLL_SECONDS:-}")" + elapsed_seconds="0" + last_state="no check data returned yet" + + log "Waiting up to ${timeout_seconds}s for GitHub to report initial checks on PR #$pr_number." + + while :; do + last_state="$(gh pr checks "$pr_number" --json name,state,workflow --jq 'map(.name + ":" + .state) | join(", ")' 2>/dev/null || printf 'no checks reported')" + check_count="$(gh pr checks "$pr_number" --json name,state,workflow --jq 'length' 2>/dev/null || printf '0')" + case "$check_count" in + ''|*[!0-9]*) + check_count="0" + ;; + esac + + if [ "$check_count" -gt 0 ]; then + log "Found $check_count initial check(s) for PR #$pr_number." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "No checks were reported for PR #$pr_number after ${timeout_seconds}s. Last observed state: $last_state. Confirm the GitHub Actions workflow triggers for the release branch, Actions is enabled, and the branch push succeeded before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +wait_for_pr_review_state() { + pr_number="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_PR_REVIEW_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_PR_REVIEW_POLL_SECONDS:-}")" + elapsed_seconds="0" + last_state="PR review/comment state has not been read yet" + + log "Waiting up to ${timeout_seconds}s for GitHub review/comment state on PR #$pr_number." + + while :; do + last_state="$(gh pr view "$pr_number" --json reviewDecision,comments,reviews --jq '"reviewDecision=" + (.reviewDecision // "") + ", comments=" + ((.comments | length) | tostring) + ", reviews=" + ((.reviews | length) | tostring)' 2>/dev/null || printf 'GitHub did not return PR review/comment state')" + case "$last_state" in + "GitHub did not return PR review/comment state") + ;; + *) + log "GitHub review/comment state is readable for PR #$pr_number: $last_state." + return 0 + ;; + esac + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "GitHub review/comment state for PR #$pr_number was not readable after ${timeout_seconds}s. Last observed state: $last_state. Confirm the PR exists and GitHub is returning review data before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +check_pr_comments() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would check PR #$pr_number for comments and requested changes." + return 0 + fi + + wait_for_pr_review_state "$pr_number" + + review_decision="$(gh pr view "$pr_number" --json reviewDecision --jq '.reviewDecision // ""')" + comment_count="$(gh pr view "$pr_number" --json comments,reviews --jq '([.comments[]?, (.reviews[]? | select(.state == "COMMENTED"))] | length)')" + + if [ "$review_decision" = "CHANGES_REQUESTED" ]; then + gh pr view "$pr_number" --comments + die "PR #$pr_number has requested changes. Address valid concerns in code, or add out-of-scope concerns to ROADMAP.md, resolve the threads, push, and rerun release.sh." + fi + + if [ "$comment_count" != "0" ] && [ "$review_comments_addressed" != "true" ]; then + gh pr view "$pr_number" --comments + die "PR #$pr_number has review or discussion comments. Address and resolve valid concerns, add out-of-scope concerns to ROADMAP.md, then rerun release.sh with --review-comments-addressed once the comment pass is intentionally complete." + fi + + log "PR #$pr_number has no blocking review state." +} + +merge_pr() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would merge PR #$pr_number into $base_branch with a merge commit and delete the remote branch." + return 0 + fi + + gh pr merge "$pr_number" --merge --delete-branch + log "Merged PR #$pr_number into $base_branch." +} + +fast_forward_base_branch() { + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would fast-forward local $base_branch from origin/$base_branch." + return 0 + fi + + git -C "$REPO_ROOT" fetch origin "$base_branch" + if git -C "$REPO_ROOT" switch "$base_branch" 2>/dev/null || git -C "$REPO_ROOT" checkout "$base_branch" 2>/dev/null; then + git -C "$REPO_ROOT" pull --ff-only origin "$base_branch" + log "Fast-forwarded local $base_branch." + else + die "Could not check out local $base_branch, likely because another worktree owns it. Fast-forward $base_branch from origin/$base_branch in that checkout, then rerun release.sh so the release tag is created from the reviewed base branch." + fi +} + +create_github_release() { + if [ "$REPO_MAINTENANCE_SKIP_GH_RELEASE" = "true" ]; then + log "Skipping GitHub release creation because --skip-gh-release was requested." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." + return 0 + fi + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + verify_github_release_prerelease_metadata "$RELEASE_TAG" + log "GitHub release $RELEASE_TAG already exists." + return 0 + fi + + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + # shellcheck disable=SC2086 + gh release create "$RELEASE_TAG" --verify-tag --generate-notes $prerelease_flag + log "Created GitHub release $RELEASE_TAG." + wait_for_github_release "$RELEASE_TAG" + verify_github_release_prerelease_metadata "$RELEASE_TAG" +} + +cleanup_merged_branches() { + release_branch_name="$1" + + if [ "$skip_branch_cleanup" = "true" ]; then + log "Skipping local merged-branch cleanup because --skip-branch-cleanup was requested." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would prune origin and delete local branches already merged into $base_branch, including $release_branch_name when safe." + return 0 + fi + + git -C "$REPO_ROOT" remote prune origin + for merged_branch in $(git -C "$REPO_ROOT" for-each-ref --format='%(refname:short)' --merged "$base_branch" refs/heads); do + case "$merged_branch" in + "$base_branch") + ;; + *) + git -C "$REPO_ROOT" branch -d "$merged_branch" >/dev/null 2>&1 || warn "Could not delete local merged branch $merged_branch; it may be checked out in another worktree." + ;; + esac + done + log "Cleaned up local branches already merged into $base_branch where safe." +} + +run_standard_release() { + ensure_git_repo + ensure_gh_cli + ensure_semver_tag + ensure_remote_ci_mode + branch_name="$(ensure_branch_release_context)" + ensure_clean_worktree + + if [ "$skip_validate" != "true" ]; then + sh "$SELF_DIR/validate-all.sh" + fi + + run_version_bump + ensure_clean_worktree + push_release_branch "$branch_name" + create_or_update_pr "$branch_name" + pr_number="$PR_NUMBER" + wait_for_initial_pr_checks "$pr_number" + if defer_remote_ci_if_requested "$pr_number" "$branch_name"; then + log "Standard release flow paused before remote CI watch for $RELEASE_TAG." + return 0 + fi + watch_ci "$pr_number" + check_pr_comments "$pr_number" + merge_pr "$pr_number" + fast_forward_base_branch + create_release_tag + push_release_tag + create_github_release + cleanup_merged_branches "$branch_name" + log "Standard release flow completed successfully for $RELEASE_TAG." +} + +if [ "$mode" = "standard" ]; then + run_standard_release + exit 0 +fi + +if [ "$skip_validate" != "true" ]; then + sh "$SELF_DIR/validate-all.sh" +fi + +log "Running repo-maintenance release flow in $REPO_MAINTENANCE_RELEASE_MODE mode for $RELEASE_TAG with the $REPO_MAINTENANCE_PROFILE profile." +run_dispatch_dir "$SELF_DIR/release" "release" + +if [ "$REPO_MAINTENANCE_RELEASE_MODE" = "submodule" ]; then + log "Submodule release finished. Update the parent repository's submodule pointer in a separate follow-up commit." +fi + +log "Repo-maintenance release flow completed successfully." diff --git a/scripts/repo-maintenance/release/10-preflight.sh b/scripts/repo-maintenance/release/10-preflight.sh new file mode 100755 index 0000000..1e6a12e --- /dev/null +++ b/scripts/repo-maintenance/release/10-preflight.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +ensure_git_repo + +case "${REPO_MAINTENANCE_RELEASE_MODE:-}" in + standard|submodule) + ;; + *) + die "Release mode must be standard or submodule." + ;; +esac + +case "${RELEASE_TAG:-}" in + v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) + ;; + *) + die "Release tag must use vX.Y.Z SemVer syntax." + ;; +esac + +branch_name="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true)" +[ -n "$branch_name" ] || die "Release workflow requires a named branch instead of detached HEAD." + +status_output="$(git -C "$REPO_ROOT" status --porcelain)" +[ -z "$status_output" ] || die "Release workflow requires a clean worktree before tagging." + +if [ "${REPO_MAINTENANCE_RELEASE_MODE:-}" = "submodule" ]; then + superproject_root="$(git -C "$REPO_ROOT" rev-parse --show-superproject-working-tree || true)" + [ -n "$superproject_root" ] || die "Submodule release mode requires this repository to be checked out as a git submodule." +fi diff --git a/scripts/repo-maintenance/release/20-tag-release.sh b/scripts/repo-maintenance/release/20-tag-release.sh new file mode 100755 index 0000000..80e147b --- /dev/null +++ b/scripts/repo-maintenance/release/20-tag-release.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" +tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" + +if [ -n "$tag_sha" ]; then + [ "$tag_sha" = "$head_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at HEAD." + log "Tag $RELEASE_TAG already points at HEAD." + exit 0 +fi + +if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then + log "Would create annotated tag $RELEASE_TAG at HEAD." + exit 0 +fi + +git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" +log "Created annotated tag $RELEASE_TAG." diff --git a/scripts/repo-maintenance/release/30-push-release.sh b/scripts/repo-maintenance/release/30-push-release.sh new file mode 100755 index 0000000..54de388 --- /dev/null +++ b/scripts/repo-maintenance/release/30-push-release.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +branch_name="$(git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD)" + +if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then + log "Would push branch $branch_name and tag $RELEASE_TAG to origin." + exit 0 +fi + +git -C "$REPO_ROOT" push -u origin "$branch_name" +wait_for_remote_branch "$branch_name" +git -C "$REPO_ROOT" push origin "$RELEASE_TAG" +wait_for_remote_tag "$RELEASE_TAG" +log "Pushed branch $branch_name and tag $RELEASE_TAG." diff --git a/scripts/repo-maintenance/release/40-github-release.sh b/scripts/repo-maintenance/release/40-github-release.sh new file mode 100755 index 0000000..2220e09 --- /dev/null +++ b/scripts/repo-maintenance/release/40-github-release.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +if [ "${REPO_MAINTENANCE_SKIP_GH_RELEASE:-false}" = "true" ]; then + log "Skipping GitHub release creation because --skip-gh-release was requested." + exit 0 +fi + +if ! command -v gh >/dev/null 2>&1; then + warn "gh is unavailable, so the release tag was pushed without creating a GitHub release object." + exit 0 +fi + +if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." + exit 0 +fi + +if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + verify_github_release_prerelease_metadata "$RELEASE_TAG" + log "GitHub release $RELEASE_TAG already exists." + exit 0 +fi + +prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" +# shellcheck disable=SC2086 +gh release create "$RELEASE_TAG" --verify-tag --generate-notes $prerelease_flag +log "Created GitHub release $RELEASE_TAG." +wait_for_github_release "$RELEASE_TAG" +verify_github_release_prerelease_metadata "$RELEASE_TAG" diff --git a/scripts/repo-maintenance/sync-shared.sh b/scripts/repo-maintenance/sync-shared.sh new file mode 100755 index 0000000..5a00c94 --- /dev/null +++ b/scripts/repo-maintenance/sync-shared.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" +. "$SELF_DIR/lib/common.sh" + +load_profile_env +ensure_git_repo +log "Running repo-maintenance shared sync from $REPO_ROOT with the $REPO_MAINTENANCE_PROFILE profile." +run_dispatch_dir "$SELF_DIR/syncing" "sync" +log "Repo-maintenance shared sync completed successfully." diff --git a/scripts/repo-maintenance/syncing/README.md b/scripts/repo-maintenance/syncing/README.md new file mode 100644 index 0000000..66ff612 --- /dev/null +++ b/scripts/repo-maintenance/syncing/README.md @@ -0,0 +1,31 @@ +# Repo-Maintenance Syncing Steps + +Small helper surface for deterministic repo-maintenance sync hooks. + +## Overview + +This directory holds repo-specific shell hooks that the shared repo-maintenance sync entrypoint can discover and run. + +### Motivation + +It exists so a repository can keep local sync follow-up steps in one predictable place without forking the shared sync entrypoint itself. + +## Setup + +Add repo-specific executable `.sh` files here only when the repository needs deterministic shared-sync follow-up steps. + +## Usage + +The top-level `scripts/repo-maintenance/sync-shared.sh` entrypoint discovers and runs every `*.sh` file in this directory in lexical order. + +## Development + +Keep each hook small, deterministic, and specific to the owning repository's guidance or packaging sync needs. + +## Verification + +Run the owning repository's shared sync entrypoint and confirm the expected repo-specific hooks execute in lexical order. + +## License + +Covered by the parent repository license. diff --git a/scripts/repo-maintenance/validate-all.sh b/scripts/repo-maintenance/validate-all.sh new file mode 100755 index 0000000..583696b --- /dev/null +++ b/scripts/repo-maintenance/validate-all.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/lib" +. "$SELF_DIR/lib/common.sh" + +load_profile_env +load_env_file "$SELF_DIR/config/validation.env" +ensure_git_repo +log "Running repo-maintenance validation from $REPO_ROOT with the $REPO_MAINTENANCE_PROFILE profile." +run_dispatch_dir "$SELF_DIR/validations" "validation" +log "Repo-maintenance validation completed successfully." diff --git a/scripts/repo-maintenance/validations/10-toolkit-layout.sh b/scripts/repo-maintenance/validations/10-toolkit-layout.sh new file mode 100755 index 0000000..7103b20 --- /dev/null +++ b/scripts/repo-maintenance/validations/10-toolkit-layout.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +for required in \ + "$REPO_MAINTENANCE_ROOT/validate-all.sh" \ + "$REPO_MAINTENANCE_ROOT/sync-shared.sh" \ + "$REPO_MAINTENANCE_ROOT/release.sh" \ + "$REPO_MAINTENANCE_ROOT/lib/common.sh" \ + "$REPO_MAINTENANCE_ROOT/config/profile.env" +do + [ -f "$required" ] || die "maintain-project-repo is missing the required file $required." +done diff --git a/scripts/repo-maintenance/validations/20-agents-guidance.sh b/scripts/repo-maintenance/validations/20-agents-guidance.sh new file mode 100755 index 0000000..2f775a7 --- /dev/null +++ b/scripts/repo-maintenance/validations/20-agents-guidance.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +if [ "${REPO_MAINTENANCE_REQUIRE_AGENTS:-true}" != "true" ]; then + log "Skipping AGENTS.md validation because REPO_MAINTENANCE_REQUIRE_AGENTS is disabled." + exit 0 +fi + +agents_path="$REPO_ROOT/AGENTS.md" +[ -f "$agents_path" ] || die "Expected $agents_path to exist so maintain-project-repo has repo guidance to complement." +[ -s "$agents_path" ] || die "Expected $agents_path to be non-empty." + +for needle in \ + "scripts/repo-maintenance/validate-all.sh" \ + "scripts/repo-maintenance/sync-shared.sh" \ + "scripts/repo-maintenance/release.sh" +do + grep -F "$needle" "$agents_path" >/dev/null 2>&1 || die "Expected $agents_path to mention $needle so the maintainer validation, sync, and release entrypoints stay discoverable." +done diff --git a/scripts/repo-maintenance/validations/30-ci-wrapper.sh b/scripts/repo-maintenance/validations/30-ci-wrapper.sh new file mode 100755 index 0000000..e6815be --- /dev/null +++ b/scripts/repo-maintenance/validations/30-ci-wrapper.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export REPO_MAINTENANCE_COMMON_DIR="$SELF_DIR/../lib" +. "$SELF_DIR/../lib/common.sh" + +workflow_path="$REPO_ROOT/.github/workflows/validate-repo-maintenance.yml" + +if [ ! -f "$workflow_path" ]; then + log "Skipping CI wrapper validation because $workflow_path is not present." + exit 0 +fi + +grep -Fq "scripts/repo-maintenance/validate-all.sh" "$workflow_path" || die "Expected $workflow_path to call scripts/repo-maintenance/validate-all.sh." From 3517620a7268d65180b4f0c982b062e14a54ae03 Mon Sep 17 00:00:00 2001 From: Gale W Date: Tue, 30 Jun 2026 23:59:34 -0400 Subject: [PATCH 2/7] models: add dense matrix result types --- Sources/MaxVol/DenseColumnMajorMatrix.swift | 77 +++++++++++++++ Sources/MaxVol/MaxVol.swift | 2 - Sources/MaxVol/MaxVolError.swift | 30 ++++++ Sources/MaxVol/MaxVolResult.swift | 18 ++++ .../DenseColumnMajorMatrixTests.swift | 98 +++++++++++++++++++ Tests/MaxVolTests/MaxVolTests.swift | 8 -- 6 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 Sources/MaxVol/DenseColumnMajorMatrix.swift delete mode 100644 Sources/MaxVol/MaxVol.swift create mode 100644 Sources/MaxVol/MaxVolError.swift create mode 100644 Sources/MaxVol/MaxVolResult.swift create mode 100644 Tests/MaxVolTests/DenseColumnMajorMatrixTests.swift delete mode 100644 Tests/MaxVolTests/MaxVolTests.swift diff --git a/Sources/MaxVol/DenseColumnMajorMatrix.swift b/Sources/MaxVol/DenseColumnMajorMatrix.swift new file mode 100644 index 0000000..08c8a4e --- /dev/null +++ b/Sources/MaxVol/DenseColumnMajorMatrix.swift @@ -0,0 +1,77 @@ +public struct DenseColumnMajorMatrix: Sendable { + public let rows: Int + public let columns: Int + public internal(set) var values: [Scalar] + + public var leadingDimension: Int { rows } + + public init(rows: Int, columns: Int, columnMajorValues values: [Scalar]) throws { + try Self.validateDimensions(rows: rows, columns: columns) + try Self.validateValueCount(rows: rows, columns: columns, valueCount: values.count) + + self.rows = rows + self.columns = columns + self.values = values + } + + public init(rows: Int, columns: Int, rowMajorValues values: [Scalar]) throws { + try Self.validateDimensions(rows: rows, columns: columns) + try Self.validateValueCount(rows: rows, columns: columns, valueCount: values.count) + + self.rows = rows + self.columns = columns + self.values = (0.. 0, columns > 0 else { + throw MaxVolError.invalidDimensions(rows: rows, columns: columns) + } + guard !rows.multipliedReportingOverflow(by: columns).overflow else { + throw MaxVolError.invalidDimensions(rows: rows, columns: columns) + } + } + + private static func validateValueCount(rows: Int, columns: Int, valueCount: Int) throws { + let expectedCount = rows * columns + guard valueCount == expectedCount else { + throw MaxVolError.malformedMatrix( + rows: rows, + columns: columns, + expectedCount: expectedCount, + actualCount: valueCount + ) + } + } + + public subscript(row row: Int, column column: Int) -> Scalar { + get { values[column * leadingDimension + row] } + set { values[column * leadingDimension + row] = newValue } + } + + public func row(_ row: Int) -> [Scalar] { + (0.. ArraySlice { + let start = column * leadingDimension + return values[start.. Self { + guard rows >= columns else { + throw MaxVolError.nonTallMatrix(rows: rows, columns: columns) + } + + return self + } +} + +extension DenseColumnMajorMatrix: Equatable where Scalar: Equatable {} +extension DenseColumnMajorMatrix: Hashable where Scalar: Hashable {} diff --git a/Sources/MaxVol/MaxVol.swift b/Sources/MaxVol/MaxVol.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/MaxVol/MaxVol.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Sources/MaxVol/MaxVolError.swift b/Sources/MaxVol/MaxVolError.swift new file mode 100644 index 0000000..40b85fd --- /dev/null +++ b/Sources/MaxVol/MaxVolError.swift @@ -0,0 +1,30 @@ +public enum MaxVolError: Error, Equatable, Sendable { + case invalidDimensions(rows: Int, columns: Int) + case malformedMatrix(rows: Int, columns: Int, expectedCount: Int, actualCount: Int) + case nonTallMatrix(rows: Int, columns: Int) + case invalidSelectionCount(requested: Int, availableRows: Int) + case rankDeficient(pivot: Int) + case lapackFailure(routine: String, info: Int32) + case maximumIterationsExceeded(limit: Int) +} + +extension MaxVolError: CustomStringConvertible { + public var description: String { + switch self { + case let .invalidDimensions(rows, columns): + "MaxVol expected positive matrix dimensions, but received rows: \(rows), columns: \(columns)." + case let .malformedMatrix(rows, columns, expectedCount, actualCount): + "MaxVol expected \(expectedCount) values for a \(rows) x \(columns) dense matrix, but received \(actualCount)." + case let .nonTallMatrix(rows, columns): + "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 .rankDeficient(pivot): + "MaxVol could not continue because the input matrix appears rank-deficient at pivot \(pivot)." + case let .lapackFailure(routine, info): + "Accelerate LAPACK routine \(routine) reported info \(info)." + case let .maximumIterationsExceeded(limit): + "MaxVol stopped after reaching the maximum iteration limit of \(limit)." + } + } +} diff --git a/Sources/MaxVol/MaxVolResult.swift b/Sources/MaxVol/MaxVolResult.swift new file mode 100644 index 0000000..290e59d --- /dev/null +++ b/Sources/MaxVol/MaxVolResult.swift @@ -0,0 +1,18 @@ +public struct MaxVolResult: Sendable { + public let selectedRows: [Int] + public let coefficients: DenseColumnMajorMatrix + public let iterations: Int + + public init( + selectedRows: [Int], + coefficients: DenseColumnMajorMatrix, + iterations: Int + ) { + self.selectedRows = selectedRows + self.coefficients = coefficients + self.iterations = iterations + } +} + +extension MaxVolResult: Equatable where Scalar: Equatable {} +extension MaxVolResult: Hashable where Scalar: Hashable {} diff --git a/Tests/MaxVolTests/DenseColumnMajorMatrixTests.swift b/Tests/MaxVolTests/DenseColumnMajorMatrixTests.swift new file mode 100644 index 0000000..4fead31 --- /dev/null +++ b/Tests/MaxVolTests/DenseColumnMajorMatrixTests.swift @@ -0,0 +1,98 @@ +@testable import MaxVol +import Testing + +struct DenseColumnMajorMatrixTests { + @Test func rowMajorInitializerStoresValuesInColumnMajorOrder() throws { + let matrix = try DenseColumnMajorMatrix( + rows: 2, + columns: 3, + rowMajorValues: [ + 1, 2, 3, + 4, 5, 6, + ] + ) + + #expect(matrix.rows == 2) + #expect(matrix.columns == 3) + #expect(matrix.leadingDimension == 2) + #expect(matrix.values == [1, 4, 2, 5, 3, 6]) + #expect(matrix[row: 1, column: 2] == 6) + } + + @Test func columnMajorInitializerPreservesStorageOrder() throws { + let matrix = try DenseColumnMajorMatrix( + rows: 3, + columns: 2, + columnMajorValues: [ + 1, 2, 3, + 4, 5, 6, + ] + ) + + #expect(matrix.values == [1, 2, 3, 4, 5, 6]) + #expect(matrix.row(1) == [2, 5]) + #expect(Array(matrix.column(1)) == [4, 5, 6]) + } + + @Test func initializerRejectsNonPositiveDimensions() throws { + #expect(throws: MaxVolError.invalidDimensions(rows: 0, columns: 2)) { + try DenseColumnMajorMatrix( + rows: 0, + columns: 2, + columnMajorValues: [Double]() + ) + } + } + + @Test func initializerRejectsMalformedValueCount() throws { + #expect( + throws: MaxVolError.malformedMatrix( + rows: 2, + columns: 3, + expectedCount: 6, + actualCount: 5 + ) + ) { + try DenseColumnMajorMatrix( + rows: 2, + columns: 3, + columnMajorValues: [1, 2, 3, 4, 5] + ) + } + } + + @Test func tallMatrixValidationRejectsWideInputs() throws { + let matrix = try DenseColumnMajorMatrix( + rows: 2, + columns: 3, + rowMajorValues: [ + 1, 2, 3, + 4, 5, 6, + ] + ) + + #expect(throws: MaxVolError.nonTallMatrix(rows: 2, columns: 3)) { + try matrix.validatedTallMatrix() + } + } + + @Test func resultStoresSelectedRowsCoefficientsAndIterations() throws { + let coefficients = try DenseColumnMajorMatrix( + rows: 2, + columns: 2, + columnMajorValues: [ + 1, 0, + 0, 1, + ] + ) + let result = MaxVolResult( + selectedRows: [0, 2], + coefficients: coefficients, + iterations: 3 + ) + + #expect(result.selectedRows == [0, 2]) + #expect(result.coefficients == coefficients) + #expect(result.iterations == 3) + } +} diff --git a/Tests/MaxVolTests/MaxVolTests.swift b/Tests/MaxVolTests/MaxVolTests.swift deleted file mode 100644 index c35d4ac..0000000 --- a/Tests/MaxVolTests/MaxVolTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Testing -@testable import MaxVol - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing -} From 4e116d0ea58cdf95dde26210daf9dd8304a42708 Mon Sep 17 00:00:00 2001 From: Gale W Date: Wed, 1 Jul 2026 00:05:36 -0400 Subject: [PATCH 3/7] models: validate matrix access and roadmap --- ROADMAP.md | 70 +++++++++ Sources/MaxVol/DenseColumnMajorMatrix.swift | 47 +++++- Sources/MaxVol/MaxVolError.swift | 21 +++ Sources/MaxVol/MaxVolResult.swift | 34 ++++- .../DenseColumnMajorMatrixTests.swift | 136 +++++++++++++++++- 5 files changed, 295 insertions(+), 13 deletions(-) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..deac0d8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,70 @@ +# MaxVol Roadmap + +## Current Focus + +- Tighten the dense column-major storage and public result/error surfaces before + building algorithm behavior on top. +- Keep the first algorithm path focused on real-valued `Double` matrices backed + by Accelerate BLAS/LAPACK. +- Preserve Swift Testing as the package test surface and expand coverage before + optimizing. + +## Algorithm Milestones + +1. Implement square MaxVol for `Double`. + - Accept a tall or square `DenseColumnMajorMatrix` where + `rows >= columns`. + - Initialize pivots with Accelerate LAPACK LU factorization. + - Solve expansion coefficients with Accelerate triangular solve routines. + - Iterate row swaps with BLAS rank-one updates. + - Return selected row indices, coefficient matrix, and iteration count. + +2. Add RectMaxVol for `Double`. + - Start from square MaxVol selected rows. + - Append rows while coefficient row norms exceed tolerance. + - Support explicit `minRows` and `maxRows` bounds. + - Reuse coefficient-update primitives from square MaxVol where practical. + +3. Add `Float` support after the `Double` API is stable. + - Mirror the `Double` API shape. + - Map to the matching single-precision Accelerate entry points. + - Keep shared validation and result semantics identical. + +## Test Coverage + +- Cover matrix storage, checked access, shape validation, and result invariants. +- Verify selected row count, uniqueness, and bounds. +- Verify reconstruction: `A ~= C * A[selectedRows, :]`. +- Cover identity, square, tall deterministic, rank-deficient, tolerance, and + maximum-iteration cases. +- Add small deterministic fixtures generated from a reference Python, Julia, or + R implementation. +- Add Release-mode validation once behavior depends on optimization-sensitive + Accelerate calls. + +## Documentation + +- Add a DocC catalog for the package. +- Document column-major storage and the relationship to Accelerate/LAPACK leading + dimensions. +- Document square MaxVol and RectMaxVol usage with small examples. +- Include algorithm limitations and non-goals before the first tagged release. +- Keep public API docs aligned with tested behavior. + +## Swift Package Index + +- Add SPI readiness checks before the first public release. +- Add `.spi.yml` only when package metadata or DocC configuration needs explicit + SPI customization. +- 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. + +## GitHub Publication + +- Create a public `gaelic-ghost/MaxVol` GitHub repository. +- Set a concise repository description and high-signal topics. +- Push the feature branch and open a focused pull request before merging to + `main`. +- Do not create a tagged release until square MaxVol has tested behavior and + documentation is present. diff --git a/Sources/MaxVol/DenseColumnMajorMatrix.swift b/Sources/MaxVol/DenseColumnMajorMatrix.swift index 08c8a4e..610eb96 100644 --- a/Sources/MaxVol/DenseColumnMajorMatrix.swift +++ b/Sources/MaxVol/DenseColumnMajorMatrix.swift @@ -48,18 +48,25 @@ public struct DenseColumnMajorMatrix: Sendable { } } - public subscript(row row: Int, column column: Int) -> Scalar { - get { values[column * leadingDimension + row] } - set { values[column * leadingDimension + row] = newValue } + public func value(row: Int, column: Int) throws -> Scalar { + try validateIndex(row: row, column: column) + return self[row: row, column: column] } - public func row(_ row: Int) -> [Scalar] { - (0.. [Scalar] { + try validateRow(row) + return (0.. ArraySlice { + public func column(_ column: Int) throws -> ArraySlice { + try validateColumn(column) let start = column * leadingDimension return values[start..: Sendable { return self } + + subscript(row row: Int, column column: Int) -> Scalar { + get { values[column * leadingDimension + row] } + set { values[column * leadingDimension + row] = newValue } + } + + private func validateIndex(row: Int, column: Int) throws { + guard (0..= 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 .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 .invalidIterationCount(iterations): + "MaxVol result cannot report a negative iteration count, but received \(iterations)." case let .rankDeficient(pivot): "MaxVol could not continue because the input matrix appears rank-deficient at pivot \(pivot)." case let .lapackFailure(routine, info): diff --git a/Sources/MaxVol/MaxVolResult.swift b/Sources/MaxVol/MaxVolResult.swift index 290e59d..2e53734 100644 --- a/Sources/MaxVol/MaxVolResult.swift +++ b/Sources/MaxVol/MaxVolResult.swift @@ -7,11 +7,43 @@ public struct MaxVolResult: Sendable { selectedRows: [Int], coefficients: DenseColumnMajorMatrix, iterations: Int - ) { + ) throws { + try Self.validate( + selectedRows: selectedRows, + coefficients: coefficients, + iterations: iterations + ) + self.selectedRows = selectedRows self.coefficients = coefficients self.iterations = iterations } + + private static func validate( + selectedRows: [Int], + coefficients: DenseColumnMajorMatrix, + iterations: Int + ) throws { + guard iterations >= 0 else { + throw MaxVolError.invalidIterationCount(iterations) + } + guard selectedRows.count == coefficients.columns else { + throw MaxVolError.coefficientColumnMismatch( + selectedRows: selectedRows.count, + coefficientColumns: coefficients.columns + ) + } + + var seenRows = Set() + for row in selectedRows { + guard (0.. Date: Wed, 1 Jul 2026 00:12:36 -0400 Subject: [PATCH 4/7] algorithm: add double square maxvol --- ROADMAP.md | 3 + Sources/MaxVol/MaxVol.swift | 257 +++++++++++++++++++ Sources/MaxVol/MaxVolError.swift | 6 + Sources/MaxVol/MaxVolOptions.swift | 23 ++ Tests/MaxVolTests/MaxVolAlgorithmTests.swift | 109 ++++++++ 5 files changed, 398 insertions(+) create mode 100644 Sources/MaxVol/MaxVol.swift create mode 100644 Sources/MaxVol/MaxVolOptions.swift create mode 100644 Tests/MaxVolTests/MaxVolAlgorithmTests.swift diff --git a/ROADMAP.md b/ROADMAP.md index deac0d8..f31a4af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,9 @@ R implementation. - Add Release-mode validation once behavior depends on optimization-sensitive Accelerate calls. +- Move Accelerate calls to the modern `ACCELERATE_NEW_LAPACK` import surface in + a focused compatibility pass; a probe showed the define changes imported + LAPACK integer type names. ## Documentation diff --git a/Sources/MaxVol/MaxVol.swift b/Sources/MaxVol/MaxVol.swift new file mode 100644 index 0000000..1b1e1b6 --- /dev/null +++ b/Sources/MaxVol/MaxVol.swift @@ -0,0 +1,257 @@ +import Accelerate + +private typealias LAPACKInt = __CLPK_integer + +public func maxVol( + _ matrix: DenseColumnMajorMatrix, + options: MaxVolOptions = MaxVolOptions() +) throws -> MaxVolResult { + let input = try matrix.validatedTallMatrix() + let options = try options.validated() + var selectedRows = try initialPivotRows(for: input) + var coefficients = try expansionCoefficients(for: input, selectedRows: selectedRows) + let maxIterations = options.maxIterations ?? max(1, input.rows * input.columns) + var iterations = 0 + + while true { + let pivot = maximumMagnitude(in: coefficients) + guard pivot.value > options.tolerance else { + return try MaxVolResult( + selectedRows: selectedRows, + coefficients: coefficients, + iterations: iterations + ) + } + guard iterations < maxIterations else { + throw MaxVolError.maximumIterationsExceeded(limit: maxIterations) + } + + selectedRows[pivot.column] = pivot.row + try replaceBasisRow( + pivotRow: pivot.row, + pivotColumn: pivot.column, + coefficients: &coefficients + ) + iterations += 1 + } +} + +private struct CoefficientPivot { + let row: Int + let column: Int + let value: Double +} + +private func initialPivotRows(for matrix: DenseColumnMajorMatrix) throws -> [Int] { + var factorization = matrix.values + var rowCount = try lapackInt(matrix.rows) + var columnCount = try lapackInt(matrix.columns) + var leadingDimension = try lapackInt(matrix.leadingDimension) + var pivots = Array(repeating: LAPACKInt(0), count: matrix.columns) + var info = LAPACKInt(0) + + factorization.withUnsafeMutableBufferPointer { factorBuffer -> Void in + pivots.withUnsafeMutableBufferPointer { pivotBuffer -> Void in + dgetrf_( + &rowCount, + &columnCount, + factorBuffer.baseAddress, + &leadingDimension, + pivotBuffer.baseAddress, + &info + ) + } + } + + try validateLapackInfo(info, routine: "dgetrf", rankDeficientInfoIsPivot: true) + + var permutation = Array(0.., + selectedRows: [Int] +) throws -> DenseColumnMajorMatrix { + let rank = selectedRows.count + var basisValues = selectedRowsForBasis(matrix: matrix, selectedRows: selectedRows) + var rowRankDimension = try lapackInt(rank) + var columnRankDimension = try lapackInt(rank) + var leadingDimension = try lapackInt(rank) + var pivots = Array(repeating: LAPACKInt(0), count: rank) + var info = LAPACKInt(0) + + basisValues.withUnsafeMutableBufferPointer { basisBuffer -> Void in + pivots.withUnsafeMutableBufferPointer { pivotBuffer -> Void in + dgetrf_( + &rowRankDimension, + &columnRankDimension, + basisBuffer.baseAddress, + &leadingDimension, + pivotBuffer.baseAddress, + &info + ) + } + } + + try validateLapackInfo(info, routine: "dgetrf", rankDeficientInfoIsPivot: true) + try validateNonsingularFactorization(basisValues, dimension: rank) + + var transposedRightHandSide = transposedValues(matrix) + var transpose = CChar(UInt8(ascii: "T")) + var solveRankDimension = try lapackInt(rank) + var basisLeadingDimension = try lapackInt(rank) + var rightHandSideLeadingDimension = try lapackInt(rank) + var rightHandSides = try lapackInt(matrix.rows) + info = 0 + + basisValues.withUnsafeMutableBufferPointer { basisBuffer -> Void in + pivots.withUnsafeMutableBufferPointer { pivotBuffer -> Void in + transposedRightHandSide.withUnsafeMutableBufferPointer { rightHandSideBuffer -> Void in + dgetrs_( + &transpose, + &solveRankDimension, + &rightHandSides, + basisBuffer.baseAddress, + &basisLeadingDimension, + pivotBuffer.baseAddress, + rightHandSideBuffer.baseAddress, + &rightHandSideLeadingDimension, + &info + ) + } + } + } + + try validateLapackInfo(info, routine: "dgetrs", rankDeficientInfoIsPivot: false) + + let coefficientValues = (0.., + selectedRows: [Int] +) -> [Double] { + (0..) -> [Double] { + (0..) -> CoefficientPivot { + var pivot = CoefficientPivot(row: 0, column: 0, value: abs(coefficients[row: 0, column: 0])) + + for column in 0.. pivot.value { + pivot = CoefficientPivot(row: row, column: column, value: magnitude) + } + } + } + + return pivot +} + +private func replaceBasisRow( + pivotRow: Int, + pivotColumn: Int, + coefficients: inout DenseColumnMajorMatrix +) throws { + let gamma = coefficients[row: pivotRow, column: pivotColumn] + guard gamma != 0 else { + throw MaxVolError.rankDeficient(pivot: pivotColumn) + } + + let replacementColumn = (0.. Void in + replacementRow.withUnsafeBufferPointer { rowBuffer -> Void in + coefficients.values.withUnsafeMutableBufferPointer { coefficientBuffer -> Void in + cblas_dger( + CblasColMajor, + rowCount, + columnCount, + -1, + columnBuffer.baseAddress, + increment, + rowBuffer.baseAddress, + increment, + coefficientBuffer.baseAddress, + leadingDimension + ) + } + } + } + + for row in 0.. LAPACKInt { + guard value <= Int(LAPACKInt.max) else { + throw MaxVolError.invalidDimensions(rows: value, columns: value) + } + + return LAPACKInt(value) +} + +private func validateNonsingularFactorization(_ values: [Double], dimension: Int) throws { + let scale = max(values.map(abs).max() ?? 0, 1) + let threshold = Double.ulpOfOne * Double(dimension) * scale + + for pivot in 0.. threshold else { + throw MaxVolError.rankDeficient(pivot: pivot + 1) + } + } +} + +private func validateLapackInfo( + _ info: LAPACKInt, + routine: String, + rankDeficientInfoIsPivot: Bool +) throws { + if info < 0 { + throw MaxVolError.lapackFailure(routine: routine, info: Int32(info)) + } + if info > 0 { + if rankDeficientInfoIsPivot { + throw MaxVolError.rankDeficient(pivot: Int(info)) + } + throw MaxVolError.lapackFailure(routine: routine, info: Int32(info)) + } +} diff --git a/Sources/MaxVol/MaxVolError.swift b/Sources/MaxVol/MaxVolError.swift index e251777..79af6df 100644 --- a/Sources/MaxVol/MaxVolError.swift +++ b/Sources/MaxVol/MaxVolError.swift @@ -9,6 +9,8 @@ public enum MaxVolError: Error, Equatable, Sendable { case invalidSelectedRowIndex(row: Int, availableRows: Int) case duplicateSelectedRow(row: Int) case coefficientColumnMismatch(selectedRows: Int, coefficientColumns: Int) + case invalidTolerance(Double) + case invalidIterationLimit(Int) case invalidIterationCount(Int) case rankDeficient(pivot: Int) case lapackFailure(routine: String, info: Int32) @@ -38,6 +40,10 @@ extension MaxVolError: CustomStringConvertible { "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 greater than 1.0, but received \(tolerance)." + case let .invalidIterationLimit(limit): + "MaxVol maximum iteration limit must be nonnegative, but received \(limit)." case let .invalidIterationCount(iterations): "MaxVol result cannot report a negative iteration count, but received \(iterations)." case let .rankDeficient(pivot): diff --git a/Sources/MaxVol/MaxVolOptions.swift b/Sources/MaxVol/MaxVolOptions.swift new file mode 100644 index 0000000..683e86a --- /dev/null +++ b/Sources/MaxVol/MaxVolOptions.swift @@ -0,0 +1,23 @@ +public struct MaxVolOptions: Equatable, Hashable, Sendable { + public let tolerance: Double + public let maxIterations: Int? + + public init(tolerance: Double = 1.05, maxIterations: Int? = nil) { + self.tolerance = tolerance + self.maxIterations = maxIterations + } + + func validated() throws -> Self { + guard tolerance.isFinite, tolerance > 1 else { + throw MaxVolError.invalidTolerance(tolerance) + } + + if let maxIterations { + guard maxIterations >= 0 else { + throw MaxVolError.invalidIterationLimit(maxIterations) + } + } + + return self + } +} diff --git a/Tests/MaxVolTests/MaxVolAlgorithmTests.swift b/Tests/MaxVolTests/MaxVolAlgorithmTests.swift new file mode 100644 index 0000000..f36f9f2 --- /dev/null +++ b/Tests/MaxVolTests/MaxVolAlgorithmTests.swift @@ -0,0 +1,109 @@ +@testable import MaxVol +import Testing + +struct MaxVolAlgorithmTests { + @Test func squareIdentityReturnsIdentityCoefficients() throws { + let matrix = try DenseColumnMajorMatrix( + rows: 2, + columns: 2, + rowMajorValues: [ + 1.0, 0.0, + 0.0, 1.0, + ] + ) + + let result = try maxVol(matrix) + + #expect(result.selectedRows == [0, 1]) + #expect(result.iterations == 0) + try expectReconstruction(of: matrix, using: result) + } + + @Test func stableTallMatrixReconstructsFromSelectedRows() throws { + let matrix = try DenseColumnMajorMatrix( + rows: 4, + columns: 2, + rowMajorValues: [ + 1.0, 0.0, + 0.0, 1.0, + 0.5, 0.25, + -0.25, 0.75, + ] + ) + + let result = try maxVol(matrix) + + #expect(result.selectedRows.count == matrix.columns) + #expect(Set(result.selectedRows).count == result.selectedRows.count) + #expect(result.selectedRows.allSatisfy { (0.., + using result: MaxVolResult, + tolerance: Double = 1e-10 + ) throws { + for row in 0.. Date: Wed, 1 Jul 2026 00:17:24 -0400 Subject: [PATCH 5/7] docs: add docc and spi metadata --- .spi.yml | 4 ++ Sources/MaxVol/DenseColumnMajorMatrix.swift | 23 +++++++++ Sources/MaxVol/MaxVol.docc/MaxVol.md | 57 +++++++++++++++++++++ Sources/MaxVol/MaxVol.swift | 5 ++ Sources/MaxVol/MaxVolError.swift | 32 ++++++++++++ Sources/MaxVol/MaxVolOptions.swift | 11 ++++ Sources/MaxVol/MaxVolResult.swift | 14 +++++ 7 files changed, 146 insertions(+) create mode 100644 .spi.yml create mode 100644 Sources/MaxVol/MaxVol.docc/MaxVol.md diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..3dd7e81 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [MaxVol] diff --git a/Sources/MaxVol/DenseColumnMajorMatrix.swift b/Sources/MaxVol/DenseColumnMajorMatrix.swift index 610eb96..1407745 100644 --- a/Sources/MaxVol/DenseColumnMajorMatrix.swift +++ b/Sources/MaxVol/DenseColumnMajorMatrix.swift @@ -1,10 +1,24 @@ +/// A dense matrix stored in column-major order. +/// +/// Column-major storage keeps values contiguous by column, matching the layout +/// expected by BLAS and LAPACK routines in Accelerate. public struct DenseColumnMajorMatrix: Sendable { + /// The number of matrix rows. public let rows: Int + + /// The number of matrix columns. public let columns: Int + + /// The matrix values in column-major order. public internal(set) var values: [Scalar] + /// The physical row stride between adjacent columns. public var leadingDimension: Int { rows } + /// Creates a matrix from values that are already arranged by column. + /// + /// The value at `(row, column)` is stored at + /// `column * leadingDimension + row`. public init(rows: Int, columns: Int, columnMajorValues values: [Scalar]) throws { try Self.validateDimensions(rows: rows, columns: columns) try Self.validateValueCount(rows: rows, columns: columns, valueCount: values.count) @@ -14,6 +28,10 @@ public struct DenseColumnMajorMatrix: Sendable { self.values = values } + /// Creates a matrix from values arranged by row. + /// + /// The initializer converts the input into column-major storage so the + /// resulting matrix can be passed directly to Accelerate-backed routines. public init(rows: Int, columns: Int, rowMajorValues values: [Scalar]) throws { try Self.validateDimensions(rows: rows, columns: columns) try Self.validateValueCount(rows: rows, columns: columns, valueCount: values.count) @@ -48,16 +66,19 @@ public struct DenseColumnMajorMatrix: Sendable { } } + /// Returns the value at a checked row and column index. public func value(row: Int, column: Int) throws -> Scalar { try validateIndex(row: row, column: column) return self[row: row, column: column] } + /// Replaces the value at a checked row and column index. public mutating func setValue(_ value: Scalar, row: Int, column: Int) throws { try validateIndex(row: row, column: column) self[row: row, column: column] = value } + /// Returns the values in a checked row index. public func row(_ row: Int) throws -> [Scalar] { try validateRow(row) return (0..: Sendable { } } + /// Returns a contiguous slice for a checked column index. public func column(_ column: Int) throws -> ArraySlice { try validateColumn(column) let start = column * leadingDimension return values[start.. Self { guard rows >= columns else { throw MaxVolError.nonTallMatrix(rows: rows, columns: columns) diff --git a/Sources/MaxVol/MaxVol.docc/MaxVol.md b/Sources/MaxVol/MaxVol.docc/MaxVol.md new file mode 100644 index 0000000..19f19c7 --- /dev/null +++ b/Sources/MaxVol/MaxVol.docc/MaxVol.md @@ -0,0 +1,57 @@ +# ``MaxVol`` + +Select high-volume row submatrices from dense matrices using Swift and +Accelerate. + +## Overview + +MaxVol provides Swift-native APIs for selecting representative rows from tall +dense matrices. The first implementation path supports real-valued `Double` +matrices stored in column-major order so the package can call Accelerate BLAS +and LAPACK routines directly. + +Use ``DenseColumnMajorMatrix`` to make matrix layout explicit at API +boundaries: + +```swift +let matrix = try DenseColumnMajorMatrix( + rows: 4, + columns: 2, + rowMajorValues: [ + 1.0, 0.0, + 0.0, 1.0, + 0.5, 0.25, + -0.25, 0.75, + ] +) +``` + +Call ``maxVol(_:options:)`` to select a square basis from a tall matrix: + +```swift +let result = try maxVol(matrix) + +print(result.selectedRows) +print(result.iterations) +``` + +The returned coefficients reconstruct the original matrix from the selected +rows: + +```swift +// A ~= C * A[selectedRows, :] +let coefficients = result.coefficients +``` + +## Topics + +### Matrix Storage + +- ``DenseColumnMajorMatrix`` + +### MaxVol + +- ``maxVol(_:options:)`` +- ``MaxVolOptions`` +- ``MaxVolResult`` +- ``MaxVolError`` diff --git a/Sources/MaxVol/MaxVol.swift b/Sources/MaxVol/MaxVol.swift index 1b1e1b6..242654d 100644 --- a/Sources/MaxVol/MaxVol.swift +++ b/Sources/MaxVol/MaxVol.swift @@ -2,6 +2,11 @@ import Accelerate private typealias LAPACKInt = __CLPK_integer +/// Selects a high-volume square row basis from a tall dense `Double` matrix. +/// +/// The returned coefficients are shaped so the input matrix `A` can be +/// approximated by `C * A[selectedRows, :]`, where `C` is +/// ``MaxVolResult/coefficients``. public func maxVol( _ matrix: DenseColumnMajorMatrix, options: MaxVolOptions = MaxVolOptions() diff --git a/Sources/MaxVol/MaxVolError.swift b/Sources/MaxVol/MaxVolError.swift index 79af6df..77bbd5e 100644 --- a/Sources/MaxVol/MaxVolError.swift +++ b/Sources/MaxVol/MaxVolError.swift @@ -1,19 +1,51 @@ +/// Errors thrown by MaxVol matrix validation and Accelerate-backed computation. public enum MaxVolError: Error, Equatable, Sendable { + /// Matrix dimensions must be positive and fit in addressable storage. case invalidDimensions(rows: Int, columns: Int) + + /// The value buffer count does not match the requested matrix shape. case malformedMatrix(rows: Int, columns: Int, expectedCount: Int, actualCount: Int) + + /// A row and column pair is outside the matrix bounds. case matrixIndexOutOfBounds(row: Int, column: Int, rows: Int, columns: Int) + + /// A row index is outside the matrix bounds. case rowIndexOutOfBounds(row: Int, rows: Int) + + /// A column index is outside the matrix bounds. case columnIndexOutOfBounds(column: Int, columns: Int) + + /// MaxVol currently requires a tall or square input matrix. case nonTallMatrix(rows: Int, columns: Int) + + /// A requested selection count is incompatible with the available rows. case invalidSelectionCount(requested: Int, availableRows: Int) + + /// A selected row index is outside the input matrix bounds. case invalidSelectedRowIndex(row: Int, availableRows: Int) + + /// A result contains the same selected row more than once. case duplicateSelectedRow(row: Int) + + /// 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 or equal to `1.0`. case invalidTolerance(Double) + + /// The maximum iteration limit is negative. case invalidIterationLimit(Int) + + /// A result reports a negative iteration count. case invalidIterationCount(Int) + + /// The selected basis is rank-deficient. case rankDeficient(pivot: Int) + + /// An Accelerate LAPACK routine reported an unexpected nonzero `info` value. case lapackFailure(routine: String, info: Int32) + + /// The algorithm did not converge before the configured iteration limit. case maximumIterationsExceeded(limit: Int) } diff --git a/Sources/MaxVol/MaxVolOptions.swift b/Sources/MaxVol/MaxVolOptions.swift index 683e86a..346563d 100644 --- a/Sources/MaxVol/MaxVolOptions.swift +++ b/Sources/MaxVol/MaxVolOptions.swift @@ -1,7 +1,18 @@ +/// Controls MaxVol row-replacement convergence. public struct MaxVolOptions: Equatable, Hashable, Sendable { + /// The maximum allowed absolute coefficient before another replacement is attempted. + /// + /// Values must be finite and greater than `1.0`. The default follows the + /// common practical MaxVol tolerance used to avoid unnecessary churn around + /// exact equality. public let tolerance: Double + + /// The maximum number of row-replacement iterations. + /// + /// `nil` uses the package default derived from the matrix dimensions. public let maxIterations: Int? + /// Creates MaxVol options. public init(tolerance: Double = 1.05, maxIterations: Int? = nil) { self.tolerance = tolerance self.maxIterations = maxIterations diff --git a/Sources/MaxVol/MaxVolResult.swift b/Sources/MaxVol/MaxVolResult.swift index 2e53734..a44ca2c 100644 --- a/Sources/MaxVol/MaxVolResult.swift +++ b/Sources/MaxVol/MaxVolResult.swift @@ -1,8 +1,22 @@ +/// The row selection and expansion coefficients produced by MaxVol. public struct MaxVolResult: Sendable { + /// The selected row indices in the input matrix. public let selectedRows: [Int] + + /// Coefficients that reconstruct the input from the selected rows. + /// + /// For an input matrix `A`, the result is shaped so + /// `A ~= coefficients * A[selectedRows, :]`. public let coefficients: DenseColumnMajorMatrix + + /// The number of row-replacement iterations performed after the initial basis. public let iterations: Int + /// Creates a validated result value. + /// + /// The selected row count must match the coefficient column count, selected + /// rows must be unique and in bounds, and the iteration count must be + /// nonnegative. public init( selectedRows: [Int], coefficients: DenseColumnMajorMatrix, From 976c26c7c725ab6f8d85327baf7529576aeba05d Mon Sep 17 00:00:00 2001 From: Gale W Date: Wed, 1 Jul 2026 00:22:20 -0400 Subject: [PATCH 6/7] build: use modern accelerate lapack --- Package.swift | 6 +++++- ROADMAP.md | 5 ++--- Sources/MaxVol/MaxVol.swift | 6 +++--- Sources/MaxVol/MaxVolError.swift | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Package.swift b/Package.swift index df931a5..b526725 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,11 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "MaxVol" + name: "MaxVol", + cSettings: [ + .define("ACCELERATE_NEW_LAPACK"), + .define("ACCELERATE_LAPACK_ILP64"), + ] ), .testTarget( name: "MaxVolTests", diff --git a/ROADMAP.md b/ROADMAP.md index f31a4af..9b81833 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,9 +41,8 @@ R implementation. - Add Release-mode validation once behavior depends on optimization-sensitive Accelerate calls. -- Move Accelerate calls to the modern `ACCELERATE_NEW_LAPACK` import surface in - a focused compatibility pass; a probe showed the define changes imported - LAPACK integer type names. +- Keep validation clean under the modern Accelerate `ACCELERATE_NEW_LAPACK` and + `ACCELERATE_LAPACK_ILP64` import surface. ## Documentation diff --git a/Sources/MaxVol/MaxVol.swift b/Sources/MaxVol/MaxVol.swift index 242654d..8f74a5f 100644 --- a/Sources/MaxVol/MaxVol.swift +++ b/Sources/MaxVol/MaxVol.swift @@ -1,6 +1,6 @@ import Accelerate -private typealias LAPACKInt = __CLPK_integer +private typealias LAPACKInt = __LAPACK_int /// Selects a high-volume square row basis from a tall dense `Double` matrix. /// @@ -251,12 +251,12 @@ private func validateLapackInfo( rankDeficientInfoIsPivot: Bool ) throws { if info < 0 { - throw MaxVolError.lapackFailure(routine: routine, info: Int32(info)) + throw MaxVolError.lapackFailure(routine: routine, info: Int(info)) } if info > 0 { if rankDeficientInfoIsPivot { throw MaxVolError.rankDeficient(pivot: Int(info)) } - throw MaxVolError.lapackFailure(routine: routine, info: Int32(info)) + throw MaxVolError.lapackFailure(routine: routine, info: Int(info)) } } diff --git a/Sources/MaxVol/MaxVolError.swift b/Sources/MaxVol/MaxVolError.swift index 77bbd5e..80ead8c 100644 --- a/Sources/MaxVol/MaxVolError.swift +++ b/Sources/MaxVol/MaxVolError.swift @@ -43,7 +43,7 @@ public enum MaxVolError: Error, Equatable, Sendable { case rankDeficient(pivot: Int) /// An Accelerate LAPACK routine reported an unexpected nonzero `info` value. - case lapackFailure(routine: String, info: Int32) + case lapackFailure(routine: String, info: Int) /// The algorithm did not converge before the configured iteration limit. case maximumIterationsExceeded(limit: Int) From d2f26c22a2da0bf23f3273d1180dd6994f5595f3 Mon Sep 17 00:00:00 2001 From: Gale W Date: Wed, 1 Jul 2026 00:37:13 -0400 Subject: [PATCH 7/7] algorithm: align maxvol iteration semantics --- README.md | 6 +- ROADMAP.md | 63 +++++--- Sources/MaxVol/MaxVol.docc/MaxVol.md | 1 + Sources/MaxVol/MaxVol.swift | 13 +- Sources/MaxVol/MaxVolError.swift | 8 +- Sources/MaxVol/MaxVolOptions.swift | 16 +-- Sources/MaxVol/MaxVolResult.swift | 7 +- .../DenseColumnMajorMatrixTests.swift | 1 + Tests/MaxVolTests/MaxVolAlgorithmTests.swift | 136 ++++++++++++++++-- 9 files changed, 197 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 940ad32..359240c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ following the same shape once the API is settled. - Initialize pivots with `dgetrf`. - Solve for expansion coefficients with triangular solves. - Iterate row swaps using the standard rank-one update with `dger`. - - Return selected row indices, coefficient matrix, and iteration count. + - Return selected row indices, coefficient matrix, iteration count, and + whether the coefficient tolerance was reached. 3. Add RectMaxVol on top of square MaxVol. - Start from square MaxVol pivots. @@ -34,7 +35,8 @@ following the same shape once the API is settled. - Verify reconstruction `A ~= C * A[pivots, :]`. - Check pivot count, uniqueness, and bounds. - Cover identity, tall random, rank-deficient, tolerance, and max-iteration cases. - - Compare small deterministic fixtures against a reference Python or Julia result. + - Compare small deterministic fixtures against reference Python, Julia, or R + results. 5. Add performance-focused refinements only after the behavior is stable. - Avoid repeated temporary allocations in the swap loop. diff --git a/ROADMAP.md b/ROADMAP.md index 9b81833..6eacf8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,12 +2,12 @@ ## Current Focus -- Tighten the dense column-major storage and public result/error surfaces before - building algorithm behavior on top. -- Keep the first algorithm path focused on real-valued `Double` matrices backed - by Accelerate BLAS/LAPACK. -- Preserve Swift Testing as the package test surface and expand coverage before - optimizing. +- Ship `v0.5.0` with square MaxVol for real-valued `Double` matrices 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 + reference parity are complete. ## Algorithm Milestones @@ -17,7 +17,8 @@ - Initialize pivots with Accelerate LAPACK LU factorization. - Solve expansion coefficients with Accelerate triangular solve routines. - Iterate row swaps with BLAS rank-one updates. - - Return selected row indices, coefficient matrix, and iteration count. + - Return selected row indices, coefficient matrix, iteration count, and + convergence status. 2. Add RectMaxVol for `Double`. - Start from square MaxVol selected rows. @@ -30,6 +31,13 @@ - Map to the matching single-precision Accelerate entry points. - Keep shared validation and result semantics identical. +4. Add reference-parity fixtures for each supported algorithm. + - Keep small deterministic fixtures generated from `maxvolpy`, `Maxvol.jl`, + or an R implementation. + - Record selected rows, coefficients, iteration counts, and convergence + status for fixture matrices that require zero, one, and multiple swaps. + - Include independent reconstruction checks for every fixture. + ## Test Coverage - Cover matrix storage, checked access, shape validation, and result invariants. @@ -37,8 +45,9 @@ - Verify reconstruction: `A ~= C * A[selectedRows, :]`. - Cover identity, square, tall deterministic, rank-deficient, tolerance, and maximum-iteration cases. -- Add small deterministic fixtures generated from a reference Python, Julia, or - R implementation. +- Keep reference fixtures generated from upstream Python, Julia, or R + implementations when algorithm behavior changes. +- Add randomized orthonormal-matrix tests similar to `Maxvol.jl` before `1.0.0`. - Add Release-mode validation once behavior depends on optimization-sensitive Accelerate calls. - Keep validation clean under the modern Accelerate `ACCELERATE_NEW_LAPACK` and @@ -46,27 +55,41 @@ ## Documentation -- Add a DocC catalog for the package. +- Maintain the DocC catalog for the package. - Document column-major storage and the relationship to Accelerate/LAPACK leading dimensions. - Document square MaxVol and RectMaxVol usage with small examples. -- Include algorithm limitations and non-goals before the first tagged release. +- Keep algorithm limitations and non-goals visible before `1.0.0`. - Keep public API docs aligned with tested behavior. +- Add a DocC article that explains tolerance, convergence status, and + iteration-limited partial results before `1.0.0`. ## Swift Package Index -- Add SPI readiness checks before the first public release. -- Add `.spi.yml` only when package metadata or DocC configuration needs explicit - SPI customization. +- 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. ## GitHub Publication -- Create a public `gaelic-ghost/MaxVol` GitHub repository. -- Set a concise repository description and high-signal topics. -- Push the feature branch and open a focused pull request before merging to - `main`. -- Do not create a tagged release until square MaxVol has tested behavior and - documentation is present. +- 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`. +- 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. +- Add broader randomized numerical tests, including orthonormal tall matrices + and near-rank-deficient cases. +- Add Release-mode validation for optimization-sensitive Accelerate behavior. +- 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. diff --git a/Sources/MaxVol/MaxVol.docc/MaxVol.md b/Sources/MaxVol/MaxVol.docc/MaxVol.md index 19f19c7..fd0d352 100644 --- a/Sources/MaxVol/MaxVol.docc/MaxVol.md +++ b/Sources/MaxVol/MaxVol.docc/MaxVol.md @@ -33,6 +33,7 @@ let result = try maxVol(matrix) print(result.selectedRows) print(result.iterations) +print(result.converged) ``` The returned coefficients reconstruct the original matrix from the selected diff --git a/Sources/MaxVol/MaxVol.swift b/Sources/MaxVol/MaxVol.swift index 8f74a5f..065e899 100644 --- a/Sources/MaxVol/MaxVol.swift +++ b/Sources/MaxVol/MaxVol.swift @@ -15,7 +15,6 @@ public func maxVol( let options = try options.validated() var selectedRows = try initialPivotRows(for: input) var coefficients = try expansionCoefficients(for: input, selectedRows: selectedRows) - let maxIterations = options.maxIterations ?? max(1, input.rows * input.columns) var iterations = 0 while true { @@ -24,11 +23,17 @@ public func maxVol( return try MaxVolResult( selectedRows: selectedRows, coefficients: coefficients, - iterations: iterations + iterations: iterations, + converged: true ) } - guard iterations < maxIterations else { - throw MaxVolError.maximumIterationsExceeded(limit: maxIterations) + guard iterations < options.maxIterations else { + return try MaxVolResult( + selectedRows: selectedRows, + coefficients: coefficients, + iterations: iterations, + converged: false + ) } selectedRows[pivot.column] = pivot.row diff --git a/Sources/MaxVol/MaxVolError.swift b/Sources/MaxVol/MaxVolError.swift index 80ead8c..7486b7f 100644 --- a/Sources/MaxVol/MaxVolError.swift +++ b/Sources/MaxVol/MaxVolError.swift @@ -30,7 +30,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 or equal to `1.0`. + /// The convergence tolerance is not finite or is less than `1.0`. case invalidTolerance(Double) /// The maximum iteration limit is negative. @@ -45,8 +45,6 @@ public enum MaxVolError: Error, Equatable, Sendable { /// An Accelerate LAPACK routine reported an unexpected nonzero `info` value. case lapackFailure(routine: String, info: Int) - /// The algorithm did not converge before the configured iteration limit. - case maximumIterationsExceeded(limit: Int) } extension MaxVolError: CustomStringConvertible { @@ -73,7 +71,7 @@ extension MaxVolError: CustomStringConvertible { 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 greater than 1.0, but received \(tolerance)." + "MaxVol tolerance must be finite and at least 1.0, but received \(tolerance)." case let .invalidIterationLimit(limit): "MaxVol maximum iteration limit must be nonnegative, but received \(limit)." case let .invalidIterationCount(iterations): @@ -82,8 +80,6 @@ extension MaxVolError: CustomStringConvertible { "MaxVol could not continue because the input matrix appears rank-deficient at pivot \(pivot)." case let .lapackFailure(routine, info): "Accelerate LAPACK routine \(routine) reported info \(info)." - case let .maximumIterationsExceeded(limit): - "MaxVol stopped after reaching the maximum iteration limit of \(limit)." } } } diff --git a/Sources/MaxVol/MaxVolOptions.swift b/Sources/MaxVol/MaxVolOptions.swift index 346563d..f86e72c 100644 --- a/Sources/MaxVol/MaxVolOptions.swift +++ b/Sources/MaxVol/MaxVolOptions.swift @@ -2,31 +2,27 @@ public struct MaxVolOptions: Equatable, Hashable, Sendable { /// The maximum allowed absolute coefficient before another replacement is attempted. /// - /// Values must be finite and greater than `1.0`. The default follows the + /// Values must be finite and at least `1.0`. The default follows the /// common practical MaxVol tolerance used to avoid unnecessary churn around /// exact equality. public let tolerance: Double /// The maximum number of row-replacement iterations. - /// - /// `nil` uses the package default derived from the matrix dimensions. - public let maxIterations: Int? + public let maxIterations: Int /// Creates MaxVol options. - public init(tolerance: Double = 1.05, maxIterations: Int? = nil) { + public init(tolerance: Double = 1.05, maxIterations: Int = 100) { self.tolerance = tolerance self.maxIterations = maxIterations } func validated() throws -> Self { - guard tolerance.isFinite, tolerance > 1 else { + guard tolerance.isFinite, tolerance >= 1 else { throw MaxVolError.invalidTolerance(tolerance) } - if let maxIterations { - guard maxIterations >= 0 else { - throw MaxVolError.invalidIterationLimit(maxIterations) - } + guard maxIterations >= 0 else { + throw MaxVolError.invalidIterationLimit(maxIterations) } return self diff --git a/Sources/MaxVol/MaxVolResult.swift b/Sources/MaxVol/MaxVolResult.swift index a44ca2c..1994cde 100644 --- a/Sources/MaxVol/MaxVolResult.swift +++ b/Sources/MaxVol/MaxVolResult.swift @@ -12,6 +12,9 @@ public struct MaxVolResult: Sendable { /// The number of row-replacement iterations performed after the initial basis. public let iterations: Int + /// Whether the coefficient matrix satisfied the configured tolerance. + public let converged: Bool + /// Creates a validated result value. /// /// The selected row count must match the coefficient column count, selected @@ -20,7 +23,8 @@ public struct MaxVolResult: Sendable { public init( selectedRows: [Int], coefficients: DenseColumnMajorMatrix, - iterations: Int + iterations: Int, + converged: Bool = true ) throws { try Self.validate( selectedRows: selectedRows, @@ -31,6 +35,7 @@ public struct MaxVolResult: Sendable { self.selectedRows = selectedRows self.coefficients = coefficients self.iterations = iterations + self.converged = converged } private static func validate( diff --git a/Tests/MaxVolTests/DenseColumnMajorMatrixTests.swift b/Tests/MaxVolTests/DenseColumnMajorMatrixTests.swift index fba43f4..39dc81c 100644 --- a/Tests/MaxVolTests/DenseColumnMajorMatrixTests.swift +++ b/Tests/MaxVolTests/DenseColumnMajorMatrixTests.swift @@ -149,6 +149,7 @@ struct DenseColumnMajorMatrixTests { #expect(result.selectedRows == [0, 1]) #expect(result.coefficients == coefficients) #expect(result.iterations == 3) + #expect(result.converged) } @Test func resultRejectsNegativeIterationCount() throws { diff --git a/Tests/MaxVolTests/MaxVolAlgorithmTests.swift b/Tests/MaxVolTests/MaxVolAlgorithmTests.swift index f36f9f2..4f0d8d4 100644 --- a/Tests/MaxVolTests/MaxVolAlgorithmTests.swift +++ b/Tests/MaxVolTests/MaxVolAlgorithmTests.swift @@ -2,6 +2,9 @@ import Testing struct MaxVolAlgorithmTests { + // Reference fixture values in this suite were generated with + // maxvolpy.maxvol.py_maxvol running on NumPy/SciPy. + @Test func squareIdentityReturnsIdentityCoefficients() throws { let matrix = try DenseColumnMajorMatrix( rows: 2, @@ -16,29 +19,111 @@ struct MaxVolAlgorithmTests { #expect(result.selectedRows == [0, 1]) #expect(result.iterations == 0) + #expect(result.converged) try expectReconstruction(of: matrix, using: result) } - @Test func stableTallMatrixReconstructsFromSelectedRows() throws { + @Test func maxVolPyReferenceFixtureMatchesPivotsAndCoefficients() throws { let matrix = try DenseColumnMajorMatrix( - rows: 4, + rows: 3, columns: 2, rowMajorValues: [ - 1.0, 0.0, + 0.25, 0.0, + 0.5, 1.0, + 0.5, 1.5, + ] + ) + + let result = try maxVol(matrix, options: MaxVolOptions(tolerance: 1.0)) + + #expect(result.selectedRows == [2, 0]) + #expect(result.iterations == 1) + #expect(result.converged) + try expectCoefficients( + result.coefficients, + rowMajorValues: [ 0.0, 1.0, - 0.5, 0.25, - -0.25, 0.75, + 2.0 / 3.0, 2.0 / 3.0, + 1.0, 0.0, ] ) + #expect(maximumAbsoluteCoefficient(in: result.coefficients) == 1.0) + try expectReconstruction(of: matrix, using: result) + } - let result = try maxVol(matrix) + @Test func zeroMaxIterationsReturnsInitialLUReferenceFixture() throws { + let matrix = try DenseColumnMajorMatrix( + rows: 3, + columns: 2, + rowMajorValues: [ + 0.25, 0.0, + 0.5, 1.0, + 0.5, 1.5, + ] + ) + + let result = try maxVol(matrix, options: MaxVolOptions(maxIterations: 0)) - #expect(result.selectedRows.count == matrix.columns) - #expect(Set(result.selectedRows).count == result.selectedRows.count) - #expect(result.selectedRows.allSatisfy { (0.., + rowMajorValues: [Double], + tolerance: Double = 1e-12 + ) throws { + let expected = try DenseColumnMajorMatrix( + rows: coefficients.rows, + columns: coefficients.columns, + rowMajorValues: rowMajorValues + ) + + for row in 0.. + ) -> Double { + coefficients.values.map(abs).max() ?? 0 + } }