クラウド Mac で検証する iOS 百分率配分と最大剰余法

クラウド Mac で検証する iOS 百分率配分と最大剰余法

クラウド Mac で検証する iOS 百分率配分と最大剰余法

iOS の統計画面で、重みが等しい三つの項目をそれぞれ 33% と表示すると、合計は 99% にしかなりません。かといって差分をすべて最後の項目に加えると、重みがゼロの項目にまで割合が割り当てられるおそれがあります。クラウド Mac のビルドパイプラインでは、この種の問題を検証するためにシミュレータを起動する必要はありません。配分アルゴリズムを純粋な Swift 関数として切り出せば、合計値、順位付け、不正な入力を検証し、その同じ結果を UI で利用できます。

表示単位と入力の契約を先に定める

ここで扱うのは、三つのカテゴリにおける完了タスク数のような、非負整数の件数です。金額、浮動小数点数の測定値、すでに丸められた百分率は対象にしません。表示上の総単位数を 100 とし、1 単位を 1 パーセントポイントに対応させます。小数第 1 位まで表示する場合は総単位数を 1000 に変え、1 単位を 0.1 パーセントポイントとします。

契約は四つです。出力の要素数は入力と同じであること。出力は非負で、合計が総単位数と一致すること。重みがゼロの項目には 0 単位を割り当てること。同じ入力には常に同じ結果を返すこと。空の配列、負の重み、重みの合計がゼロの場合は、そのままエラーにします。独自の判断で均等配分を生成してはいけません。

「データがない」と「すべての項目の割合が等しい」は、業務上異なる状態です。すべてゼロの入力は画面側で空状態として表示し、配分アルゴリズムによって有効な統計に見せかけてはいけません。

剰余が等しい場合は、入力のインデックス順で優先順位を決めます。そのため、呼び出し側はあらかじめ、安定したカテゴリ識別子に基づいてデータを並べる必要があります。辞書の走査順をプロダクトのルールとして扱うと、画面を更新しただけで補充分を受け取る項目が変わる可能性があります。

整数の最大剰余法で不足分を配る

まず、各項目の重みに総単位数を掛け、重みの合計で割ります。商を基本配分とし、剰余を補充の優先順位付けに使います。基本配分の合計で足りない分は、剰余が大きい項目から順に 1 単位ずつ加えます。

各項目で切り捨てを行うため、補充する単位数は必ず項目数より小さくなります。値が収束するまでループする必要も、浮動小数点数の許容誤差を設ける必要もありません。次のコードを percentage-check.swift として保存してください。実装と最小限のチェックをまとめてあります。

enum AllocationError: Error {
    case invalidInput
    case overflow
}

func allocate(_ weights: [Int], units: Int = 100) throws -> [Int] {
    guard !weights.isEmpty,
          (1...10_000).contains(units),
          weights.allSatisfy({ $0 >= 0 }) else {
        throw AllocationError.invalidInput
    }

    var total = 0
    for weight in weights {
        let sum = total.addingReportingOverflow(weight)
        guard !sum.overflow else {
            throw AllocationError.overflow
        }
        total = sum.partialValue
    }
    guard total > 0 else {
        throw AllocationError.invalidInput
    }

    var result = [Int]()
    var remainders = [Int]()
    for weight in weights {
        let product = weight.multipliedReportingOverflow(by: units)
        guard !product.overflow else {
            throw AllocationError.overflow
        }
        result.append(product.partialValue / total)
        remainders.append(product.partialValue % total)
    }

    let remaining = units - result.reduce(0, +)
    let order = weights.indices.sorted {
        if remainders[$0] == remainders[$1] {
            return $0 < $1
        }
        return remainders[$0] > remainders[$1]
    }
    for index in order.prefix(remaining) {
        result[index] += 1
    }
    return result
}

func expectFailure(_ weights: [Int], units: Int = 100) {
    do {
        _ = try allocate(weights, units: units)
    } catch {
        return
    }
    preconditionFailure("Expected allocation to fail")
}

let equal = try allocate([1, 1, 1])
precondition(equal == [34, 33, 33])

let withZero = try allocate([0, 1, 1])
precondition(withZero == [0, 50, 50])

let tenths = try allocate([1, 1, 1], units: 1000)
precondition(tenths == [334, 333, 333])

for a in 0...12 {
    for b in 0...12 {
        for c in 0...12 {
            let weights = [a, b, c]
            let total = weights.reduce(0, +)
            if total == 0 { continue }

            let output = try allocate(weights)
            precondition(output.count == weights.count)
            precondition(output.reduce(0, +) == 100)
            precondition(output.allSatisfy { $0 >= 0 })

            for index in weights.indices {
                let floorQuota = weights[index] * 100 / total
                precondition(
                    output[index] == floorQuota ||
                    output[index] == floorQuota + 1
                )
                if weights[index] == 0 {
                    precondition(output[index] == 0)
                }
            }
        }
    }
}

expectFailure([])
expectFailure([0, 0])
expectFailure([-1, 2])
expectFailure([1], units: 0)
expectFailure([Int.max, 1])
expectFailure([Int.max])

このコードでは、加算と乗算の両方で明示的にオーバーフローを検出しています。最終的な百分率が小さくても、計算途中の重みの積が整数の範囲を超えることはあります。ここでは入力を拒否し、浮動小数点数への変換で精度の変化を覆い隠さない方針にしています。さらに大きな件数を受け付ける必要がある場合は、より広いビット幅の整数演算を別途設計してください。

クラウド Mac で独立した検証を実行する

まず xcrun swift --version で現在のツールチェーンを確認し、続いて次のコマンドを実行します。チェックに成功した場合は何も出力されません。アサーション違反や未処理のエラーが発生するとプロセスは失敗するため、パイプラインではその終了ステータスを必ず引き継いでください。

set -eu
xcrun swift percentage-check.swift

正式なマージ条件として使う場合は、xcrun swiftc -Onone でコンパイルしてから実行する方法もあります。これらのアサーションを無効にする最適化モードは使えません。長期的には、関数をアプリケーションのモジュールに置き、アサーションをテストターゲットへ移して、既存のテストコマンドで実行する方が確実です。これにより、スクリプトとプロダクトで同じアルゴリズムを別々に保守せずに済みます。

ここでは、三つの重みについて小さな範囲の組み合わせを列挙しています。性能を測るベンチマークではありません。すべてゼロに近いケース、一つの項目だけが全体を占めるケース、剰余が等しいケース、小さな重みが切り捨てでゼロになるケースなどを網羅します。テストを追加する際は、同じ均等なサンプルを繰り返すのではなく、入力のパターンを広げてください。

丸め誤差と業務ルールを切り分ける

最も見落としやすいのは、ソート後に元の項目との対応関係を失うことです。この実装はインデックスだけをソートし、最終的な配列は元の項目順に対応したままです。UI で剰余の順位をそのままカテゴリの表示順にしてはいけません。

また、「正の重みを持つ項目には、必ず 1 単位以上を表示する」という条件も課さないでください。カテゴリ数が表示の総単位数を超えると、この条件と合計値の維持は両立しません。ごく小さな割合を示したい場合は、表示精度を上げるか、「表示しきい値未満」といった文言を別途用意できます。ただし、配分を暗黙に変更してはいけません。

最後に、この方法がデータセットをまたぐ単調性まで保証するとは考えないでください。他のカテゴリの重みや表示精度を変えると、剰余の順位が変わることがあります。テストでは現在のデータに対する配分の範囲を検証し、特定の項目の値が更新のたびに決して減らない、といった条件を置くべきではありません。

小数の書式設定は表示だけを担う

総単位数が 1000 の場合、334 は 33.4% を表します。書式設定 API が 0 から 1 の比率を受け取る仕様なら、渡すのは 334 / 1000 に相当する値であり、さらに 100 を掛ける必要はありません。書式設定の段階で項目ごとに元の重みから再計算したり、別の精度に丸め直したりしてはいけません。

失敗を検証可能な記録として残す

マージ前に、入力の重み、総単位数、基本配分、剰余の順位、最終出力を記録しておきます。本番の統計に機密性の高いカテゴリが含まれる可能性がある場合は、固定のテストフィクスチャで再現し、実際の業務データの詳細をビルド成果物に書き込まないでください。

レビューで重視する境界は三つです。不正な入力に対して明確に失敗するか。剰余が等しい場合でも安定して優先順位を決められるか。UI の書式設定が所定の精度を保っているか。検証を通過したら、データ層は重みだけを、配分層は整数の単位数だけを提供し、表示層はローカライズされた書式設定だけを担当する構成にします。三つの層の責務を分ければ、百分率の合計に異常が出ても原因を具体的な処理段階に絞り込めます。最後の項目に差分を押し込んで、見かけ上だけ正しくする必要はありません。

よくある質問

割合を一項目ずつ四捨五入してはいけませんか?

個別の丸めでは合計を保証できません。同じ重みが三つなら、それぞれを三十三パーセントと表示すると合計は九十九になります。最大剰余法では整数部分を配った後、余りの大きい順に不足分を補います。

重みが増えれば表示割合も必ず増えますか?

異なる入力間での単調性までは保証しません。他の重みや表示単位数が変わると剰余の順位も変わります。この実装が保証するのは、各入力での合計、割当範囲、結果の決定性です。

いつものパイプラインでクラウドMacを検証

ArmRDPでは、仮想マシンではない専有の物理Mac miniをご利用いただけます。1日単位でレンタルできるので、ツールチェーンやビルド結果を確認してから、チームに合った利用期間をお選びください。

クラウドMacをレンタル

構成を明確に、ビルドを再現可能に

機種、利用期間、接続手順を確認してから、タスクを専有物理ノードにお任せください。