Проверка процентов iOS методом наибольших остатков на облачном Mac

Проверка процентов iOS методом наибольших остатков на облачном Mac

На экране статистики iOS три одинаковых веса отображаются как 33% каждый, но в сумме получается лишь 99%. Если отдать всю разницу последнему элементу, ненулевой процент может достаться даже элементу с нулевым весом. В сборочном конвейере на облачном Mac для проверки таких случаев не нужен симулятор: достаточно вынести алгоритм распределения в чистую функцию Swift, проверить сумму, порядок и некорректные входные данные, а затем передавать интерфейсу тот же результат.

Определяем единицы отображения и контракт входных данных

Здесь мы работаем с неотрицательными целочисленными счётчиками — например, количеством выполненных задач в трёх категориях, — а не с денежными суммами, измерениями с плавающей точкой или уже округлёнными процентами. Пусть общее число единиц отображения равно 100: тогда одна единица соответствует одному процентному пункту. Если нужен один знак после запятой, используем 1000 единиц, каждая из которых соответствует одной десятой процентного пункта.

Контракт состоит из четырёх условий: длина результата совпадает с длиной входного массива; все значения результата неотрицательны и в сумме равны общему числу единиц; нулевой вес получает ноль единиц; одинаковые входные данные всегда дают одинаковый результат. Пустой массив, отрицательные веса и нулевая сумма весов должны приводить к ошибке, а не к самовольному равномерному распределению.

«Нет данных» и «все доли равны» — разные состояния с точки зрения бизнес-логики. При полностью нулевых входных данных страница должна показывать пустое состояние: алгоритм распределения не должен выдавать их за корректную статистику.

При равных остатках приоритет определяется индексом во входном массиве. Поэтому вызывающий код должен заранее упорядочить данные по стабильным идентификаторам категорий. Не стоит превращать порядок обхода словаря в продуктовое правило: иначе после обновления страницы дополнительная единица может достаться другому элементу.

Восполняем недостающие единицы целочисленным методом наибольших остатков

Сначала умножаем каждый вес на общее число единиц, затем делим на сумму весов. Частное даёт базовую квоту, а остаток определяет очерёдность распределения дополнительных единиц. Если сумма базовых квот меньше заданного итога, добавляем по одной единице элементам в порядке убывания остатков.

Поскольку каждая квота округляется вниз, число недостающих единиц строго меньше числа элементов. Не нужны ни цикл до сходимости, ни допуск для вычислений с плавающей точкой. Сохраните следующий код в файл 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, а затем запустить. Нельзя использовать режим оптимизации, отключающий эти проверки. Более надёжный долгосрочный вариант — поместить функцию в модуль приложения, перенести проверки в тестовый таргет и запускать их существующей командой тестирования. Так не придётся поддерживать отдельные копии алгоритма в скрипте и продукте.

Здесь перебираются комбинации трёх весов в небольшом диапазоне, а не измеряется производительность. Перебор охватывает значения вблизи полностью нулевого набора, случаи с единственным ненулевым весом, равными остатками и малыми весами, округляемыми до нуля. Добавляя тесты, расширяйте разнообразие входных данных, а не просто повторяйте один и тот же равномерный набор.

Отделяем ошибки округления от бизнес-правил

Чаще всего упускают потерю связи с исходными элементами после сортировки. В реализации сортируются только индексы, поэтому итоговый массив по-прежнему соответствует исходным элементам. Интерфейс не должен использовать порядок остатков как порядок отображения категорий.

Кроме того, не следует требовать, чтобы «каждый положительный вес получал хотя бы одну единицу». Если категорий больше, чем единиц отображения, это требование несовместимо с сохранением общей суммы. Для очень малых долей можно повысить точность отображения или отдельно выводить текст «ниже порога отображения», но нельзя незаметно менять квоты.

Наконец, этот метод не гарантирует монотонность при переходе от одного набора данных к другому. Если изменить веса других категорий или точность отображения, порядок остатков может поменяться. Тесты должны проверять допустимый диапазон квот для текущих данных, а не требовать, чтобы значение отдельного элемента никогда не уменьшалось при обновлениях.

Форматирование дробных значений отвечает только за отображение

При общем числе единиц 1000 значение 334 означает 33,4%. Если API форматирования принимает долю в диапазоне от нуля до единицы, ему нужно передать значение, соответствующее 334 / 1000, без дополнительного умножения на сто. На этапе форматирования нельзя заново рассчитывать доли по исходным весам для каждого элемента или повторно округлять их до другой точности.

Делаем сбои воспроизводимыми и проверяемыми

Перед слиянием изменений сохраните входные веса, общее число единиц, базовые квоты, порядок остатков и итоговый результат. Если производственная статистика может содержать чувствительные категории, воспроизводите случай на фиксированных тестовых данных и не записывайте реальные бизнес-данные в артефакты сборки.

При ревью важно проверить три границы: явно ли отклоняются некорректные входные данные; стабильно ли разрешаются равные остатки; сохраняет ли форматирование в интерфейсе заданную точность. После успешной проверки слой данных должен предоставлять только веса, слой распределения — только целое число единиц для каждого элемента, а слой отображения — отвечать только за локализованное форматирование. Разделение этих трёх обязанностей позволяет найти конкретный этап, на котором нарушается сумма процентов, вместо того чтобы создавать видимость корректности за счёт последнего элемента.

Часто задаваемые вопросы

Почему нельзя округлять каждую долю отдельно?

Независимое округление не сохраняет итог. Три равных веса, показанные как 33 процента каждый, дают 99 процентов. Метод сначала выделяет целые квоты, затем распределяет оставшиеся единицы по величине остатков.

Всегда ли увеличение веса повышает показанный процент?

Для разных наборов входных данных такая гарантия не даётся. Изменение других весов или числа единиц может изменить порядок остатков. Здесь проверяются сумма, границы квот и детерминированность результата.

Проверьте облачный Mac на собственной сборке

ArmRDP предоставляет выделенный физический Mac mini, а не виртуальную машину. Арендуйте посуточно: сначала проверьте инструменты и результат сборки, затем выберите подходящий срок аренды для команды.

Арендовать облачный Mac

Проверяйте конфигурацию и воспроизводите сборки

Проверьте модель, срок аренды и порядок подключения, а затем перенесите задачи на выделенный физический узел.