정수 수량을 비율로 나눌 때 전체 합계를 보존하기

낱개 100개를 세 곳에 똑같이 나누면 계산값은 각각 100/3개입니다. 각자
33개씩 주면 1개가 남고, 각자 34개씩 주면 총량을 2개 초과합니다.
먼저 정수 몫을 배분하고, 남은 개수만 소수 나머지가 큰 순서로
하나씩 더하면 배분 합계를 100개로 맞출 수 있습니다.
나머지가
같을 때 누구에게 먼저 줄지도 계산 전에 정합니다.

이 실습에서는 실제 재고 대신 가상 낱개 100개와 대상 A·B·C를
사용합니다. 가중치는 모두 1이고, 동점이면 ID가 앞선 곳에 먼저 한 개를
줍니다. 결과는 A=34, B=33, C=33이며, 원본 CSV를 바꾸지 않는 새 JSON
보고서에 계산 근거를 남깁니다. 이 순서는 예제에서 정한 규칙이며 가중치의
공정성이나 실제 출고 승인을 대신하지 않습니다.

1 총량과 동점 순서를 먼저
정합니다

이번 규칙은 다음 순서입니다.

  1. 각 대상의 정확한 몫을 ‘총량 × 해당 가중치 ÷ 가중치 합’으로
    계산합니다
  2. 정확한 몫에서 정수 부분만 먼저 배분합니다
  3. 총량에서 정수 부분의 합을 빼 남은 개수를 구합니다
  4. 소수 나머지가 큰 대상부터 한 개씩 더합니다
  5. 나머지가 같으면 대소문자를 구분한 ASCII ID 오름차순을
    적용합니다

가중치가 1·1·1이면 세 곳의 정확한 몫은 모두 100/3입니다. 정수 부분은
33·33·33으로 합계 99, 남은 수량은 1개입니다. 나머지도 모두 1/3이므로
ID가 가장 앞선 A에 한 개를 더합니다. 같은 입력을 C·A·B 순서로 저장해도
ID 기준의 결과는 34·33·33으로 같습니다.

ID 정렬은 사람이 생각하는 숫자 순서와 다를 수 있습니다. 이 코드에서는
A10이 A2보다 앞입니다. 대상 이름이나 행 위치로 동점을 풀고 싶다면 이
코드를 실행하기 전에 해당 규칙부터 정해야 합니다. 같은 나머지에서 첫
입력 행을 고르는 방식으로 몰래 바꾸지 않습니다.

2 세 행의 가상 가중치를
확인합니다

실습 ZIP을 새 폴더에 풀고 fixtures/equal.csv를 텍스트
편집기로 읽습니다. 머리글을 포함한 파일 내용은 다음과 같습니다.

id,weight
A,1
B,1
C,1

weight는 배분할 개수 자체가 아니라 배분 비율을 정하는
가중치입니다. 가중치가 전부 양수일 필요는 없지만 합계는 양수여야 합니다.
가중치 0인 대상은 몫이 0이며, 가중치가 전부 0이면 기준이 없으므로 계산을
멈춥니다.

입력은 UTF-8 CSV, 열은 id,weight 두 개로 고정합니다.
ID는 영문자로 시작하는 영문·숫자·밑줄·하이픈 조합이며 최대 32자, 중복은
허용하지 않습니다. 가중치는 0 이상의 일반 정수·소수 문자열로 받습니다.
정수부와 소수부는 각각 최대 12자리이며, NaN, 음수, 빈칸,
지수 표기와 1/3 같은 분수 표기는 받지 않습니다. 이 조건은
실습의 입력 범위이며 Python 숫자 기능 전체의 제한은 아닙니다.

총량은 0~1,000,000,000의 정수, 대상은 최대 1,000행, 파일은 최대
1,000,000바이트입니다. 총량을 1.5로 주거나 가중치가 잘못된
행이 있으면 전체 배분을 보류합니다. 잘못된 값을 0으로 바꿔 합계를 맞추지
않습니다.

3 정확한 몫과
나머지를 계산하는 전체 코드

다음 코드는 ZIP의 code/allocate.py와 동일합니다. Python
3.12 표준 라이브러리만 사용하므로 패키지 설치나 API 키가 필요하지
않습니다.

가중치 문자열은 Fraction으로 읽습니다. Python
Fraction 공식 문서
는 문자열에서 분수를 만들 수 있으며,
부동소수점에서 만든 분수는 기대한 십진수와 다를 수 있다고 설명합니다.
여기서는 float()를 거치지 않고 0.1 문자열을
정확한 1/10으로 읽습니다. 계산 중 나머지도 분수로 비교하므로 화면에
반올림한 소수로 순서를 정하지 않습니다.

CSV는 문자열 필드로 읽고 입력 조건을 확인한 뒤 계산합니다. Python
csv 공식 문서
에서 기본 reader의 문자열 반환과 CSV 줄바꿈 처리를
확인할 수 있습니다. source_record는 머리글을 1로 센 논리
레코드 번호입니다. 따옴표 안 줄바꿈이 있는 CSV에서는 물리 줄 번호와 다를
수 있습니다.

"""Allocate synthetic whole units by exact quota and largest remainder."""
import argparse
import csv
import hashlib
import io
import json
import re
import sys
from fractions import Fraction
from pathlib import Path

ID_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,31}\Z")
WEIGHT_PATTERN = re.compile(r"(?:0|[1-9][0-9]{0,11})(?:\.[0-9]{1,12})?\Z")
TOTAL_PATTERN = re.compile(r"(?:0|[1-9][0-9]{0,9})\Z")
MAX_BYTES = 1_000_000
MAX_TARGETS = 1_000


def allocate(total_text, rows):
    if not isinstance(total_text, str) or not TOTAL_PATTERN.fullmatch(total_text):
        raise ValueError("총량은 0~1,000,000,000의 정수 문자열로 지정하세요")
    total = int(total_text)
    if total > 1_000_000_000:
        raise ValueError("총량 한도 1,000,000,000을 초과했습니다")
    if not rows or len(rows) > MAX_TARGETS:
        raise ValueError("배분 대상은 1~1,000행이어야 합니다")
    seen, checked = set(), []
    for row in rows:
        ident, raw = row["id"], row["raw_weight"]
        if not ID_PATTERN.fullmatch(ident):
            raise ValueError(f"레코드 {row['source_record']}: ID 형식 확인")
        if ident in seen:
            raise ValueError(f"중복 ID: {ident}")
        seen.add(ident)
        if not WEIGHT_PATTERN.fullmatch(raw):
            raise ValueError(f"레코드 {row['source_record']}: 가중치 형식 확인")
        checked.append((row, Fraction(raw)))
    weight_sum = sum((weight for _, weight in checked), Fraction(0))
    if weight_sum == 0:
        raise ValueError("가중치 합이 0이라 배분할 수 없습니다")

    calculated = []
    for row, weight in checked:
        quota = total * weight / weight_sum
        base = quota.numerator // quota.denominator
        calculated.append({**row, "quota": quota, "base": base,
                           "fraction": quota - base})
    remaining = total - sum(row["base"] for row in calculated)
    ranked = sorted(calculated, key=lambda row: (-row["fraction"], row["id"]))
    winners = [row["id"] for row in ranked[:remaining]]
    winner_set = set(winners)
    output = []
    for row in sorted(calculated, key=lambda row: row["id"]):
        extra = int(row["id"] in winner_set)
        output.append({
            "id": row["id"], "source_record": row["source_record"],
            "raw_weight": row["raw_weight"], "exact_quota": str(row["quota"]),
            "base": row["base"], "remainder": str(row["fraction"]),
            "extra": extra, "allocated": row["base"] + extra,
        })
    allocated_total = sum(row["allocated"] for row in output)
    if allocated_total != total:
        raise ValueError("내부 검산 실패: 배분 합계가 총량과 다릅니다")
    return {
        "method": "largest_remainder", "tie_break": "ASCII id ascending",
        "total": total, "target_count": len(output),
        "base_total": total - remaining, "remaining": remaining,
        "extras_awarded_to": winners, "allocated_total": allocated_total,
        "rows": output,
    }


def inspect(source, total_text):
    with Path(source).open("rb") as handle:
        raw_bytes = handle.read(MAX_BYTES + 1)
    if len(raw_bytes) > MAX_BYTES:
        raise ValueError("실습 입력 한도 1,000,000바이트를 초과했습니다")
    try:
        text = raw_bytes.decode("utf-8-sig", errors="strict")
    except UnicodeDecodeError as error:
        raise ValueError("UTF-8 해석 실패") from error
    rows = []
    try:
        reader = csv.reader(io.StringIO(text, newline=""), strict=True)
        if next(reader, None) != ["id", "weight"]:
            raise ValueError("머리글은 id,weight 순서여야 합니다")
        for record_number, fields in enumerate(reader, 2):
            if len(fields) != 2:
                raise ValueError(f"레코드 {record_number}: 열 수는 2개여야 합니다")
            rows.append({"id": fields[0], "raw_weight": fields[1],
                         "source_record": record_number})
            if len(rows) > MAX_TARGETS:
                raise ValueError("배분 대상 한도 1,000행을 초과했습니다")
    except csv.Error as error:
        raise ValueError("CSV 구문 오류: " + str(error)) from error
    report = allocate(total_text, rows)
    report["source_sha256"] = hashlib.sha256(raw_bytes).hexdigest()
    report["source_changes_written"] = 0
    return report


def write_report(source, total_text, target):
    source, target = Path(source), Path(target)
    if source.resolve() == target.resolve():
        raise ValueError("입력과 보고서는 서로 다른 경로여야 합니다")
    if target.suffix.lower() != ".json":
        raise ValueError("보고서는 새 .json 파일로 지정하세요")
    report = inspect(source, total_text)
    rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
    target.parent.mkdir(parents=True, exist_ok=True)
    try:
        with target.open("x", encoding="utf-8", newline="\n") as handle:
            handle.write(rendered)
    except FileExistsError as error:
        raise ValueError("기존 보고서 보존: 새 --report 이름을 지정하세요") from error
    return report


def main():
    parser = argparse.ArgumentParser(description="가상 정수 수량의 비율 배분과 합계 검산")
    parser.add_argument("--input", required=True)
    parser.add_argument("--total", required=True)
    parser.add_argument("--report", required=True)
    args = parser.parse_args()
    try:
        report = write_report(args.input, args.total, args.report)
    except (ValueError, OSError) as error:
        print("오류: " + str(error), file=sys.stderr)
        return 2
    counts = ", ".join(f"{row['id']}={row['allocated']}" for row in report["rows"])
    print(f"{counts} / 합계 {report['allocated_total']} / 원본 변경 0")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

ranked는 나머지 내림차순, ID 오름차순의 두 기준으로
정렬한 목록입니다. 그 앞에서 남은 개수만큼을 골라 각 대상에게 한 개씩
더합니다. 보고서는 ID 순서로 출력하면서 원래 레코드 번호와 입력 가중치
문자열을 따로 보존합니다. 따라서 1.00을 입력한 흔적도
사라지지 않습니다.

4 첫 결과에서 34와 33과
33을 확인합니다

터미널을 ZIP을 푼 폴더로 옮긴 뒤 실행합니다. out 폴더는
필요하면 자동으로 생깁니다.

python code/allocate.py --input fixtures/equal.csv --total 100 --report out/equal.json

정상 실행 시 다음 문구가 나옵니다.

A=34, B=33, C=33 / 합계 100 / 원본 변경 0

out/equal.json의 값은 다음과 같습니다. 보고서의
remainder는 정수 부분을 뺀 소수 나머지이며 남은 개수 1과
구분합니다.

ID 정확한 몫 정수 부분 소수 나머지 추가 최종 수량
A 100/3 33 1/3 1 34
B 100/3 33 1/3 0 33
C 100/3 33 1/3 0 33

base_total은 99, remaining은 1,
extras_awarded_to는 ["A"],
allocated_total은 100입니다. 최종 합계가 맞는 것과 A가 한
개를 더 받은 이유를 함께 확인하세요.

다음 파일은 가중치가 같은 세 행을 C·A·B 순서로 저장했습니다.

python code/allocate.py --input fixtures/reordered.csv --total 100 --report out/reordered.json

ID별 최종 배분은 같습니다. 다만 원래 행 위치를 기록한
source_record와 파일 바이트를 기록한
source_sha256은 달라집니다. 같은 배분 결과라는 말이 두 입력
파일까지 같은 파일이라는 뜻은 아닙니다.

5 가중치가 다르면
나머지를 다시 비교합니다

fixtures/unequal.csv의 가중치는 A=1, B=2, C=4입니다.
총량 10을 다음과 같이 나눕니다.

python code/allocate.py --input fixtures/unequal.csv --total 10 --report out/unequal.json
ID 정확한 몫 정수 부분 소수 나머지 추가 최종 수량
A 10/7 1 3/7 0 1
B 20/7 2 6/7 1 3
C 40/7 5 5/7 1 6
정확한 몫10/7 20/7 40/7의 소수나머지3/7 6/7 5/7을 비교해 B다음C에 추가하는 설명용 도해
설명용 도해: 가상 총량10의 기본 배분1·2·5는 합8입니다. 나머지6/7인 B와5/7인 C에 하나씩 더해1·3·6, 합10이 됩니다.

정수 부분의 합은 8이므로 2개가 남습니다. 소수 나머지는 B의 6/7, C의
5/7, A의 3/7 순서라 B와 C에 하나씩 더합니다. 최종 1+3+6=10입니다.
가중치가 가장 큰 곳이 언제나 남은 수량을 먼저 받는다고 가정하지
마세요.

소수 가중치 0.1·0.2·0.4를 총량 7로 계산하는 예제도 있습니다.

python code/allocate.py --input fixtures/decimal.csv --total 7 --report out/decimal.json

이 결과는 A=1, B=2, C=4이며 잔여는 0입니다.
fixtures/zero_weight.csv는 A=0, B=1, C=1입니다. 총량
1에서는 A=0, B=1, C=0으로, 가중치가 0인 A에 남은 수량을 주지
않습니다.

python code/allocate.py --input fixtures/zero_weight.csv --total 1 --report out/zero_weight.json

6 오류 입력과
기존 보고서를 그대로 보존합니다

아래 명령은 서로 다른 반례입니다. 각각 종료코드 2로 끝나며 새 배분
보고서가 생기지 않아야 합니다.

python code/allocate.py --input fixtures/negative.csv --total 100 --report out/rejected_negative.json
python code/allocate.py --input fixtures/all_zero.csv --total 100 --report out/rejected_zero.json
python code/allocate.py --input fixtures/duplicate_id.csv --total 100 --report out/rejected_id.json
python code/allocate.py --input fixtures/not_number.csv --total 100 --report out/rejected_number.json
python code/allocate.py --input fixtures/equal.csv --total 1.5 --report out/rejected_total.json
  • negative.csv는 B의 가중치가 -1입니다. 음수 가중치를
    임의로 양수나 0으로 바꾸지 않습니다
  • all_zero.csv는 모든 가중치가 0입니다. 총량을 나눌
    비율이 없어 멈춥니다
  • duplicate_id.csv는 A가 두 번 나옵니다. 같은 대상인지
    서로 다른 대상인지 확인하기 전 합치지 않습니다
  • not_number.csv는 가중치가 NaN입니다. 계산 가능한 일반
    숫자로 받아들이지 않습니다
  • 총량 1.5는 낱개 정수 규칙에 맞지 않습니다. 소수 부분을 버려 1개로
    처리하지 않습니다

앞에서 만든 out/equal.json을 같은 이름으로 다시 지정하면
기존 보고서를 보존하며 멈춥니다. 재실행할 때는
--report out/equal_again.json처럼 새 이름을 지정하세요.
입력과 보고서 경로를 같게 지정하는 것도 거부합니다. Python
파일 열기 공식 문서
의 배타적 생성 모드 x를 사용해 기존
파일에 쓰지 않습니다.

단, 저장 장치 오류로 새 보고서 쓰기가 중간에 실패하면 불완전한 새
파일이 남을 수 있습니다. 오류가 난 파일을 완료 결과로 사용하지 말고
원인을 해결한 뒤 새 이름으로 다시 실행하세요. 원본 SHA는 읽어들인
바이트의 기록이며 이후 변경을 차단하는 잠금은 아닙니다.

7 실습 파일과 재실행 검산

정수 수량 배분 실습 ZIP 받기

ZIP에는 전체 코드, 아홉 가상 CSV, 고정 기대값, README와 시험 코드가
들어 있습니다. README의 모든 명령을 실행한 뒤 다음 두 검사를 확인할 수
있습니다.

python -m unittest discover -s tests -v
python tests/verify_examples.py

검수는 Linux의 Python 3.12.14에서 수행했습니다. 단위시험 25개와 CLI
예제 12건이 통과했고, 가상 입력의 SHA256은 실행 전후 같았습니다. CLI
검사는 새 임시 폴더에서 정상 여섯 결과와 거부 여섯 결과를 대조합니다.
Windows와 Office 앱 실행은 확인하지 않았습니다.

업무 자료로 바꾸기 전에는 배분가능 총량의 시점과 단위, 가중치의 출처,
동일한 나머지의 순서를 먼저 확정하세요. 이 실습이 확인하는 것은 정해진
숫자와 규칙에서 정수 배분 합계가 보존되는지입니다.

작성자 소개