人が読んで打つクーポンコード — 値オブジェクト × NFKC × 検査文字 × 条件付き UPDATE(Bad→Good 10点)

2026-10-12 月曜(Day 151) A コーディング/アルゴリズム ★★★★★ 書籍 第5章・第3章・第13章・第10章 合成入力 60,000 回で実測 26テスト緑・Bad 11赤・変異14種すべて赤

概要

🔤

str ではなく値オブジェクト

文字の集合・区切り・読み替え・検査文字を CouponCode の 1 か所に閉じる。不正なコードの CouponCode は存在できない(第5章・第3章)。

🈁

NFKC は「ー」とゼロ幅を畳まない

全角英数・全角ハイフンは畳むが、長音記号「ー」・マイナス「−」・U+200B は残る(Python 3.14.7 で実測)。区切りを明示的に除き、残る非 ASCII は upper() の前に拒否。

📊

効いたのは正規化、検査文字は「失敗の後」

1 回目の成功率: Bad 26% → 後輩案2(正規化だけ)96.2% ≒ Good 96.0%。検査文字の価値は失敗の 99.8% に「打ち間違い」と言えることと、自動訂正(98.9%)。

🔒

同時押しと総当たり

SELECT→UPDATE は同時押しで [True, True](実測)。条件付き UPDATE で 1 人に。36×8 文字は 1 日 8,640 万回の推測で 6.1 個当たる → 32×10 文字で 0.015 個(導出)。

問題

MOps 販促システムチームは、休眠会員の掘り起こしで 1 回限りのクーポンコード 20 万個(チラシ 12 万・SMS 8 万。数値は架空)を配った。会員はアプリのクーポン画面にコードを打ち込んで引き換える(ログイン必須)。既存のテストは「発行したコードをそのまま redeem すると True」の 1 本だけで緑。ステージングでは QA がコードをコピー&ペーストで確かめたので問題は出なかった。

配布初日の声(架空)

声中身
Q1 使えない「チラシのとおり打ったのに『無効なコードです』」。CS への問い合わせが初日だけで 1,900 件
Q2 切り分けられないCS は「打ち間違い」「存在しない」「誰かが使った」を画面から区別できない
Q3 二重に使われた家族で同じチラシを見た 2 人が同時に押し、2 人とも割引を受けた
Q4 当てられる?セキュリティ担当「8 文字なら総当たりで当たるのでは。乱数は何を使っている?」

後輩からの直し方の提案

  • 後輩案1: redeem の最初で input_code.replace('-', '') するだけ(1 行)
  • 後輩案2: unicodedata.normalize('NFKC', …) してから区切りに見える文字を全部消す(文字の集合と長さは Bad のまま)

設問

  1. Bad の問題点を 8 つ以上、Q1〜Q4 のどれに効くかと合わせて
  2. コードを文字列のまま扱わない値オブジェクト CouponCode と、引き換えのリポジトリ(Google スタイル docstring・インラインコメント・名前付き定数)
  3. 入力の揺れ(全角・「ー」・ゼロ幅文字・小文字・O と 0)を例で、検査文字の性質を性質テストで固定。同じ約束のテストを Bad にも当てる
  4. 同じ合成入力で Bad・後輩案1・後輩案2・Good を比べ、正規化で済む部分と、文字の集合・検査文字でしか得られない部分を分けて示す
  5. 検査文字の方式(なし/単純和/Luhn mod 32/Crockford mod 37)を打ち間違いの型ごとの検出率で比べて選ぶ
  6. 変異テストでテストの強さを確かめる
  7. 迷った案と採らなかった理由、切り替える条件

制約: Python 3.12+、標準ライブラリのみ(テストは pytest・hypothesis)。DB は本番 Cloud SQL と同じ SQL を SQLite で動かす。印刷物は次回から変えてよい。レート制限は別部品(ここでは数値の根拠だけ)。

悪いコード (Before)

既存テスト 1 本は緑。人が打つ経路を 1 本もテストしていないので、10 の問題 がすべて隠れていた。
coupon_bad.py(現行)
import random
import string


def generate_codes(n, length=8):
    chars = string.ascii_uppercase + string.digits
    return [''.join(random.choice(chars) for _ in range(length)) for _ in range(n)]


def display(code):
    return code[:4] + '-' + code[4:]


class CouponStore:
    def __init__(self, db):
        self.db = db
        self.db.execute(
            "CREATE TABLE IF NOT EXISTS coupon_codes (code TEXT, used INTEGER DEFAULT 0, member_id TEXT)")

    def redeem(self, input_code, member_id):
        code = input_code.strip().upper()
        row = self.db.execute("SELECT used FROM coupon_codes WHERE code = ?", (code,)).fetchone()
        if row is None or row[0]:
            return False
        self.db.execute("UPDATE coupon_codes SET used = 1, member_id = ? WHERE code = ?", (member_id, code))
        self.db.commit()
        return True
問題点サマリー(10点)
1コードを str のまま扱う — 規則が 1 行と bool に溶ける(第5章)
2印刷どおり「-」付きで打つと通らない — 失敗の 82.9%
3全角・「ー」・全角空白が通らない
4ゼロ幅文字が残る — strip() は U+200B を落とさない
5見間違えやすい文字を両方使う — 36 中 12 文字
6打ち間違いと未発行を区別できない(第13章)
7SELECT→UPDATE — 同時押しで 2 人とも成功
8random で発行 — 種が分かれば再現
98 文字・36 種 — 1 日の総当たりで期待 6.1 個
10一意制約・索引なし

ヒント(段階的開示)

ヒント1 — 方向性
Q1〜Q4 はどれも「コードを str のまま扱っている」ことから来る。str には「どの文字が使えるか」「区切りは意味を持つか」「打ち間違いと別のコードをどう見分けるか」が書けない。第5章の「プリミティブ型執着」を、人が打つ識別子に当てはめるとどうなるか。Q3 は「調べてから書く」の間に何が起きうるかの話。
ヒント2 — アプローチ
  • 文字の集合を先に決める。Crockford Base32 は 0-9 と A-Z から I・L・O・U を除いた 32 文字で、入力では I と L を 1、O を 0 と読む
  • 正規化の順序: NFKC → 区切りの除去 → 大文字化 → 読み替え。NFKC が何を畳まないかを自分で確かめる
  • 集合外の文字が残ったら黙って捨てない(第10章の握り潰しと同じ形)
  • 末尾に 1 文字の検査文字で「打ち間違い」と「存在しない」を区別する。32 文字の中に収まる方式がよい
  • 乱数は secrets
  • 引き換えは UPDATE … WHERE code = ? AND redeemed_by IS NULL の 1 文。更新件数で結果を決める
ヒント3 — 骨格
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"   # Crockford Base32
_READ_AS = {"I": "1", "L": "1", "O": "0"}

def normalize_input(raw: str) -> str:
    folded = unicodedata.normalize("NFKC", raw)
    out = []
    for ch in folded:
        if ch in _SEPARATORS or ch.isspace():
            continue
        ...  # 非 ASCII なら拒否 → upper → 読み替え → 集合外なら拒否
    return "".join(out)

@dataclass(frozen=True, slots=True)
class CouponCode:
    value: str
    def __post_init__(self): ...  # 長さ・集合・検査文字。不正ならインスタンスを作らせない
    @classmethod
    def parse(cls, raw: str) -> Self: return cls(normalize_input(raw))

問題点分析(10点・実測)

実測の条件: macOS(Apple M1)、Python 3.14.7(sim)/3.13.12・pytest 9.1.1・hypothesis 6.169.0(テスト)。発行 200,000 個・引き換え 60,000 回・シード 20261012。人の入力の分布はすべて仮定 — SMS 40%(うちペースト 70%、ペーストの 6% にゼロ幅文字等)、区切りも打つ 55%、全角 15%、小文字 25%、見間違いうる文字を 1.5%/文字で見間違い、打ち間違い 1.0%・入れ替え 0.4%・抜け 0.1%。

#問題効く声実測・根拠
1コードを str のまま扱う(第5章)Q1・Q2#2〜#6 はすべてここから
2印刷した形(7K30-Q5XB)で打つと通らないQ1Bad の失敗 44,525 件のうち 36,890 件(82.9%) 実測
3全角・長音記号「ー」・全角空白が通らない。upper() は全角 a を全角 A にするだけQ1非 ASCII を含む失敗 6,718 件 実測。区切り 6 種のテストが Bad で全部赤
4SMS からのコピーに混ざるゼロ幅文字が残るQ1'​'.strip() は '​' のまま 実測
5見間違えやすい文字(0/O・1/I・5/S・8/B・2/Z・6/G)を両方使うQ1・Q2見間違い・打ち間違いの失敗 888 件 実測
6打ち間違いと未発行を区別できない(検査文字なし・bool 返却、第13章)Q2二度押しも False(demo)
7SELECT してから UPDATEQ3同時押しテストで [True, True] 実測
8random(メルセンヌ・ツイスタ)で発行Q4同じ種から同じ 3 個 ['0ADA-C97E', 'OZDK-O99O', 'E8U1-H8IF'] 実測。状態復元攻撃は未実演
98 文字・36 種(length=8 に根拠なし)Q41 日 8,640 万回の推測で 期待 6.1 個 当たる 導出
10一意制約・索引なしQ4・運用重複の期待値 0.0071 個 導出。照合が全件走査で、sim 専用に索引を張った
既存テストとステージングの QA は「DB と同じ形の文字列をそのまま渡す」経路しか通らなかった。人が打つ経路を 1 本もテストしていなかった のが本当の穴。

正規化の流れと同時押し(SVG)

上: 人の入力「7K30ーQ5XBー2PC​」が CouponCode になるまで(Good) 人の入力 7K30ーQ5XBー 2PC+U+200B 全角・長音・ゼロ幅 ① NFKC 7K30ーQ5XBー2PC​ 全角英数は半角に 「ー」とゼロ幅は残る ② 区切りを除く 7K30Q5XB2PC - ‐ – − ー 空白 U+200B 等を明示列挙 ③ 非ASCIIを拒否 → upper → 読み替え I,L→1 / O→0 ı は upper で I になる→先に拒否 ④ CouponCode.__post_init__ 長さ 11 → 集合内 → 検査文字 7K30Q5XB2P + C(Luhn mod 32) 通らなければインスタンスが作れない 集合外 → BAD_CHARACTER BAD_LENGTH CHECK_MISMATCH =打ち間違い 黙って捨てない(捨てると別の 発行済みコードに化けうる。変異 M4) 下: 2 人が同時に同じコードを押す(テストでは UPDATE の直前で 2 本のスレッドを揃えて再現) Bad: SELECT してから UPDATE 会員 A 会員 B SELECT used → 0 SELECT used → 0 どちらも「未使用」を読んだ(ここで Barrier) UPDATE used=1 → True UPDATE used=1 → True 結果 [True, True](実測)— 割引が 2 回 Good: 条件付き UPDATE 1 文(compare-and-set) 会員 A 会員 B UPDATE の直前で揃える(Barrier) UPDATE … WHERE code=?AND redeemed_by IS NULL → 1 行 同じ UPDATE(A の確定を待つ)条件が偽 → 0 行 REDEEMED SELECT → USED_BY_OTHER 結果 [False, True](実測)— 1 人だけ 変異 M10(UPDATE から「AND redeemed_by IS NULL」を外す)で、同時押し・2 人目・結果 4 通りの 3 本のテストが赤になることを確かめた。PostgreSQL での同時実行は未測定。

上段は正規化の順序。NFKC だけでは「ー」とゼロ幅文字が残る(実測)ので②で明示的に除き、③で非 ASCII を upper() より先に拒否する。下段は Q3 の原因と直し方。

Bad→Good 対比(要所)

Before
chars = string.ascii_uppercase + string.digits
''.join(random.choice(chars) for _ in range(8))



code = input_code.strip().upper()




row = db.execute("SELECT used … WHERE code = ?")
if row is None or row[0]:
    return False
db.execute("UPDATE … SET used = 1 WHERE code = ?")
return True
After
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"  # Crockford
body = "".join(secrets.choice(ALPHABET) for _ in range(10))
code = CouponCode(body + _luhn_mod_n_check(body))

code = CouponCode.parse(raw)  # 値オブジェクト
  NFKC → 区切り除去
  → 非ASCII拒否 → upper → I,L→1 / O→0
  → 長さ・集合・検査文字(不正は例外と理由)
cur = db.execute("UPDATE … WHERE code = ?
                  AND redeemed_by IS NULL")
if cur.rowcount == 1: return REDEEMED
… NOT_FOUND / ALREADY_YOURS / USED_BY_OTHER
code TEXT PRIMARY KEY

模範解答: coupon_good.py(全文 190 行)

黄色はインラインコメント(設計意図)。コード一式: logs/2026-10-12-A-coding-code/

"""coupon_good.py(改善後・Good): 人が読んで打つ 1 回限りクーポンコード.

方針:
  - コードは値オブジェクト `CouponCode` にし、作れる経路を「発行」と「人の入力の解釈」の 2 つに絞る
    (プリミティブ型執着をやめる。文字列のまま DB に渡さない)
  - 文字の集合は Crockford Base32(0-9 と A-Z から I・L・O・U を除いた 32 文字)。
    入力では I・L を 1、O を 0 と読む(Crockford の仕様どおり)
  - 末尾 1 文字は Luhn mod N(N=32)の検査文字。1 文字の打ち間違いはすべて、隣り合う 2 文字の入れ替えは
    1 組を除いて「存在しないコード」ではなく「打ち間違い」と区別できる
  - 人の入力は NFKC(全角→半角)→ 区切り文字の除去 → 大文字化 → 読み替え の順で正規化し、
    それでも集合外の文字が残れば黙って捨てずに拒否する(捨てると別のコードに化けうる)
  - 乱数は secrets(暗号論的乱数)。random(メルセンヌ・ツイスタ)は出力から内部状態を復元できる
  - 引き換えは「未使用なら使用済みにする」を 1 文の条件付き UPDATE で行い、結果を 5 通りの状態で返す
"""

from __future__ import annotations

import secrets
import sqlite3
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Self

ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"  # Crockford Base32。I L O U を含まない
_VALUE = {ch: i for i, ch in enumerate(ALPHABET)}
_RADIX = len(ALPHABET)  # 32
_READ_AS = {"I": "1", "L": "1", "O": "0"}  # Crockford の「読み替え」。U は読み替えずに拒否する

BODY_LENGTH = 10  # 32^10 ≒ 1.13e15 通り。長さの根拠は README の (7) を参照
CODE_LENGTH = BODY_LENGTH + 1  # 末尾 1 文字が検査文字
_GROUPS = (4, 4, 3)  # 印刷・SMS での見せ方。区切りは入力時に捨てる

# NFKC の後でも残る「区切りに見える文字」。NFKC は全角ハイフン(U+FF0D)と全角スペースしか畳まない
_SEPARATORS = frozenset(
    " -_."
    "\u2010\u2011\u2012\u2013\u2014\u2015\u2212"  # ハイフン・ダッシュ・マイナス記号
    "\u30fc"  # 長音記号「ー」(日本語入力のまま「-」を打つと出る。半角ー も NFKC でこれになる)
    "\u200b\u200c\u200d\u2060\ufeff"  # ゼロ幅文字(SMS・LINE からのコピーで混ざる)
)


class InvalidReason(StrEnum):
    """入力をコードとして解釈できなかった理由。画面の文言と CS の切り分けに使う."""

    BAD_CHARACTER = "bad_character"  # 集合外の文字(U、記号、かな など)
    BAD_LENGTH = "bad_length"  # 区切りを除いた長さが 11 文字でない
    CHECK_MISMATCH = "check_mismatch"  # 形は正しいが検査文字が合わない=打ち間違い


class InvalidCouponCode(ValueError):
    """人の入力をコードと解釈できない。bool の False でなく理由と位置を持たせる."""

    def __init__(self, reason: InvalidReason, detail: str, position: int | None = None) -> None:
        super().__init__(f"{reason}: {detail}")
        self.reason = reason
        self.position = position  # 区切りを除いた後の 0 始まりの位置(BAD_CHARACTER のとき)


def _luhn_mod_n_check(body: str) -> str:
    """Luhn mod N で検査文字を出す(右端から 1 つおきに 2 倍し、N 進の桁和を足す)."""
    total = 0
    factor = 2  # 検査文字を付けた後の右から 2 番目が 2 倍になるよう、本体の右端から 2 倍で始める
    for ch in reversed(body):
        addend = factor * _VALUE[ch]
        total += addend // _RADIX + addend % _RADIX  # N 進で 2 桁になったら桁を足す
        factor = 1 if factor == 2 else 2
    return ALPHABET[(_RADIX - total % _RADIX) % _RADIX]


def normalize_input(raw: str) -> str:
    """人の入力を、集合内の文字だけの列に直す。直せない文字があれば例外.

    Raises:
        InvalidCouponCode: 集合外の文字が残った場合(reason=BAD_CHARACTER)。
    """
    folded = unicodedata.normalize("NFKC", raw)  # 全角英数・全角ハイフン・全角スペースを半角に
    out: list[str] = []
    for ch in folded:
        if ch in _SEPARATORS or ch.isspace():
            continue  # 区切りは意味を持たないので捨てる(位置は数えない)
        if not ch.isascii():
            # NFKC で ASCII にならなかった文字(ı・かな 等)は upper() で ASCII に化けうるので先に拒否する
            raise InvalidCouponCode(InvalidReason.BAD_CHARACTER, repr(ch), len(out))
        up = _READ_AS.get(ch.upper(), ch.upper())
        if up not in _VALUE:
            raise InvalidCouponCode(InvalidReason.BAD_CHARACTER, repr(ch), len(out))
        out.append(up)
    return "".join(out)


@dataclass(frozen=True, slots=True)
class CouponCode:
    """検査文字まで正しいクーポンコード。不正な値ではインスタンスを作れない(完全コンストラクタ)."""

    value: str  # 正規化済み 11 文字(本体 10 + 検査 1)

    def __post_init__(self) -> None:
        if len(self.value) != CODE_LENGTH:
            raise InvalidCouponCode(InvalidReason.BAD_LENGTH, f"{len(self.value)} 文字({CODE_LENGTH} 文字のはず)")
        bad = next((i for i, ch in enumerate(self.value) if ch not in _VALUE), None)
        if bad is not None:  # 「if bad := …:」だと先頭(位置 0)の不正文字を見逃す
            raise InvalidCouponCode(InvalidReason.BAD_CHARACTER, repr(self.value[bad]), bad)
        if _luhn_mod_n_check(self.value[:-1]) != self.value[-1]:
            raise InvalidCouponCode(InvalidReason.CHECK_MISMATCH, "検査文字が一致しない")

    @classmethod
    def issue(cls) -> Self:
        """新しいコードを 1 つ作る。重複の排除は DB の主キーに任せる(CouponRepository.issue)."""
        body = "".join(secrets.choice(ALPHABET) for _ in range(BODY_LENGTH))
        return cls(body + _luhn_mod_n_check(body))

    @classmethod
    def parse(cls, raw: str) -> Self:
        """人の入力(全角・小文字・区切り・O/0 の取り違えを含みうる)を解釈する.

        Raises:
            InvalidCouponCode: 集合外の文字・長さ違い・検査文字の不一致。
        """
        # 長さと検査文字の確認は __post_init__ に 1 か所だけ置く(変異テスト 1 回目で、ここに置いた同じ確認が
        # 消しても何も変わらない「等価変異」だと分かったため外した)
        return cls(normalize_input(raw))

    def display(self) -> str:
        """印刷・SMS 用の見せ方(例: 7K30-Q5XB-2PC)."""
        parts, i = [], 0
        for size in _GROUPS:
            parts.append(self.value[i : i + size])
            i += size
        return "-".join(parts)


class RedeemStatus(StrEnum):
    REDEEMED = "redeemed"  # 今回の呼び出しで使用済みにした
    ALREADY_YOURS = "already_yours"  # 同じ会員がすでに使った(二度押し・再送)。成功として扱ってよい
    USED_BY_OTHER = "used_by_other"  # 別の会員が使った
    NOT_FOUND = "not_found"  # 形も検査文字も正しいが発行していない


@dataclass(frozen=True, slots=True)
class RedeemResult:
    status: RedeemStatus
    code: CouponCode


class CouponRepository:
    """クーポンコードの表。本番は Cloud SQL(PostgreSQL)。ここでは同じ SQL を SQLite で動かす."""

    def __init__(self, db: sqlite3.Connection) -> None:
        self._db = db
        self._db.execute(
            "CREATE TABLE IF NOT EXISTS coupon_codes ("
            " code TEXT PRIMARY KEY,"  # 主キー=重複発行を DB が止める
            " redeemed_by TEXT,"
            " redeemed_at TEXT)"
        )

    def issue(self, count: int) -> list[CouponCode]:
        """count 個を発行して返す。衝突したら作り直す(32^10 通りなので 100 万個でも期待 0.0004 回)."""
        issued: list[CouponCode] = []
        while len(issued) < count:
            code = CouponCode.issue()
            cur = self._db.execute("INSERT OR IGNORE INTO coupon_codes (code) VALUES (?)", (code.value,))
            if cur.rowcount == 1:
                issued.append(code)
        self._db.commit()
        return issued

    def redeem(self, raw: str, member_id: str, at: datetime) -> RedeemResult:
        """人の入力を解釈して引き換える。何度呼んでも 1 つのコードは 1 人にしか渡らない.

        Raises:
            InvalidCouponCode: 入力をコードと解釈できない場合(DB には問い合わせない)。
        """
        code = CouponCode.parse(raw)
        # 「未使用なら使用済みにする」を 1 文で行う。SELECT してから UPDATE すると、その間に別の要求が割り込む
        cur = self._db.execute(
            "UPDATE coupon_codes SET redeemed_by = ?, redeemed_at = ? WHERE code = ? AND redeemed_by IS NULL",
            (member_id, at.isoformat(), code.value),
        )
        self._db.commit()
        if cur.rowcount == 1:
            return RedeemResult(RedeemStatus.REDEEMED, code)
        row = self._db.execute("SELECT redeemed_by FROM coupon_codes WHERE code = ?", (code.value,)).fetchone()
        if row is None:
            return RedeemResult(RedeemStatus.NOT_FOUND, code)
        if row[0] == member_id:
            return RedeemResult(RedeemStatus.ALREADY_YOURS, code)
        return RedeemResult(RedeemStatus.USED_BY_OTHER, code)

PostgreSQL では同じ UPDATE に RETURNING redeemed_by を付ければ、更新できなかったときの SELECT を 1 往復減らせる(SQLite と同じ形を保つため分けた)。

適用した設計パターンと書籍の章

パターンどこ書籍
値オブジェクト(プリミティブ型執着をやめる)CouponCode。DB に渡るのは code.value だけ第5章
完全コンストラクタ・不変__post_init__ で長さ・集合・検査文字、frozen=True第3章
ファクトリメソッドissue()(発行)と parse(raw)(人の入力)第5章
bool を返さない・結果を区別RedeemStatus 4 種、InvalidCouponCode(reason, position)第13章
握り潰さない(黙って捨てない)集合外の文字は BAD_CHARACTER第10章
マジックナンバーの定数化BODY_LENGTH・_GROUPS・_SEPARATORS・_READ_AS第10章
二重の確認をしない長さ確認を __post_init__ の 1 か所に(等価変異から)第10章
条件付き UPDATE(compare-and-set)AND redeemed_by IS NULL の更新件数―

比較: 何が効いたか(合成入力 60,000 回・3 回実行)

実装1 回目の成功率失敗したとき会員に言えること
Bad25.79〜26.32%「無効なコード」だけ
後輩案1(ハイフンだけ除去)85.49〜85.57%同上
後輩案2(NFKC+区切り除去。集合・長さは Bad のまま)96.16〜96.24%同上
Good95.96〜96.12%失敗 2,378 件中 2,373 件(99.8%)が「打ち間違い」と分かる。残る 5 件は 2 文字以上の誤りが偶然検査を通った「未発行」
Good+自動訂正(採らなかった案 D)98.82〜98.90%同上
今日いちばん大事な結果: 1 回目の成功率は 後輩案2 と Good がほぼ同じ(Good のほうが 0.1〜0.3pt 低い。11 文字で打つ文字が 3 つ多いため)。Q1 の 9 割以上は正規化で直る → 後輩案2 は今日すぐ出す。文字の集合・検査文字・値オブジェクトが効くのは「失敗した 4%」と Q2〜Q4。数えずに Good を出すと「Good が 26%→96% にした」と誤った手柄を書くことになる。

仮定を動かしたとき(sensitivity.py)

区切りも打つ全角見間違い/文字Bad後輩案2Good自動訂正
20%5%0.5%52.98%97.98%97.86%98.86%
20%5%3.0%49.66%93.35%93.31%98.72%
55%15%1.5%(基準)26.14%96.12%96.07%98.92%
80%30%1.5%9.66%96.22%96.01%98.92%
80%30%3.0%9.29%93.60%93.26%98.75%

Bad は「区切りも打つ割合」でほぼ決まる(9〜53%)。後輩案2 と Good の差はどの組でも 0.4pt 以内。見間違いが多いと、検査文字を使った自動訂正だけが 98.7% を保つ。

検査文字の方式(checkdigits.py、本体 2,000 個 × 誤りを全数)

方式1 文字置換隣接入替1 つ飛ばし入替双子 aa→bb見間違い使う文字
検査なし0%0%0%0%0%32
単純和 mod 32100%9.74%10.78%87.49%100%32
Luhn mod 32(採用)100%99.85%(見逃すのは 0 と Z の組だけ)0%97.81%100%32
Crockford mod 37100%100%100%92.55%100%37(* ~ $ = U)
Luhn mod 32 を採る。 Crockford mod 37 は表の上では全勝だが、検査文字に * ~ $ = U が出る(5/37 ≒ 13.5% のコード・導出)。スマホの日本語キーボードでは検査文字そのものが新しい打ち間違いの源になる。Luhn が苦手な「1 つ飛ばし入替」は人の打ち間違いの 0.5〜1.5% 程度とされる種類(Verhoeff 1969 の分類、Wikipedia の要約で確認・原典未確認)。
切り替える条件: QR・リンクで配り人が打たなくなったら検査文字は不要。電話で読み上げる運用が増えたら、1 つ飛ばしも見抜く方式(Damm の 32 次の準群、または検査文字 2 文字)を検討。

コードの長さの根拠(導出)

当たる期待個数 ≒ 推測回数 G × 未使用数 N ÷ 空間。N = 200,000、G = 毎秒 1,000 回 × 1 日 = 8,640 万回(仮定: 会員を大量に作られる前提)。

形空間1 回の当たり確率G = 100 万G = 8,640 万
Bad(36 × 8)2.82×10^127.09×10^-80.071 個6.1 個
Crockford 32 × 81.10×10^121.82×10^-70.18 個15.7 個
Good(32 × 10)1.13×10^151.78×10^-100.0002 個0.015 個
36 × 10(集合は Bad のまま伸ばす)3.66×10^155.47×10^-110.0001 個0.0047 個

32 × 8 は Bad より弱い。だから「文字の集合を Crockford にするなら、長さを 2 文字足す」が 1 組の判断。検査文字は空間を広げない(攻撃者も計算できる)ので本体 10 文字で数える。

テスト・変異テスト・実行例

結果(実測)

対象結果
Good(全 26 本)26 passed(1.2〜1.8 秒)
Bad(共通の約束 12 本、IMPL=bad -m spec)11 failed, 1 passed。通ったのは「同じコードは 2 人目には渡らない」(順番に押すなら Bad でも止まる)。同時に押すと [True, True]

テストの要所

@spec
def test_同時に押しても1人にしか渡らない(tmp_path):
    ...
    class Gate:  # UPDATE を出す直前で相手を待つ接続(SELECT→UPDATE の間に割り込まれる状況を作る)
        def execute(self, sql, *args):
            if sql.lstrip().upper().startswith("UPDATE"):
                barrier.wait()
            return self.conn.execute(sql, *args)
    ...
    assert sorted(results.values()) == [False, True]


@settings(max_examples=300, deadline=None)
@given(bodies, st.integers(0, good.CODE_LENGTH - 1), st.sampled_from(good.ALPHABET))
def test_1文字の打ち間違いは必ず打ち間違いと分かる(body, pos, ch):
    code = body + good._luhn_mod_n_check(body)
    if code[pos] == ch:
        return
    typo = code[:pos] + ch + code[pos + 1:]
    with pytest.raises(good.InvalidCouponCode) as e:
        good.CouponCode.parse(typo)
    assert e.value.reason is good.InvalidReason.CHECK_MISMATCH


def test_Luhnの検査文字は既知の例と一致する():
    # 手計算: 本体 "0000000001" の右端 1 を 2 倍 → 2。(32 - 2) % 32 = 30 → ALPHABET[30] = "Y"
    assert good._luhn_mod_n_check("0000000001") == "Y"

変異テスト(mutate.py)

変異1 回目(24 本)直した後(26 本)
M1 NFKC をやめる赤(3 本)赤(3 本)
M2 長音記号「ー」を区切りに数えない赤赤
M3 ゼロ幅文字を区切りに数えない赤赤
M4 集合外の文字を黙って捨てる赤赤
M5 非 ASCII の先行拒否をやめる(ı が I→1 に化ける)赤赤
M6 O を 0 と読まない緑(生存)赤
M7 Luhn の桁和をやめる赤赤
M8 Luhn の 2 倍の位置をずらす赤赤
M9 検査文字を確かめない赤赤
M10 UPDATE の「未使用なら」条件を外す赤(3 本)赤(3 本)
M11 二度押しを区別しない赤赤
M12 乱数を random にする赤赤
M13 発行の衝突を数えない赤赤
M14(1 回目)parse の長さ確認を外す緑(生存)―(確認ごと削除)
M14(2 回目)不正な文字を「長さ違い」と報告する(未作成)赤
  • M6 が生き残った。 読み替えのテストが乱数のコードの 0 を O に置き換える書き方で、0 を含まない回((31/32)^11 ≒ 70%・導出)は何も試していなかった。固定の本体で O・I・小文字 l を試す形に直した
  • M14 は等価変異だった。 同じ長さ確認が __post_init__ にもあり、消しても振る舞いが変わらない。二重の確認なので外し、ついでに「集合外の文字」を「長さ違い」と報告していた取り違えを分けた
  • 書き直しの途中で if bad := next(…, None): が 位置 0 の不正文字を見逃す(0 は偽)ことにレビューで気づき、is not None に直して先頭が U の例を足した。変異テストでは見つからない種類の誤り

実行例(demo.py。ケースごとに新しい表で試す)

印刷: Bad 7K30-Q5XB   Good 7K30-Q5XB-2PC

印刷どおり            Bad '7K30-Q5XB'          -> False  Good '7K30-Q5XB-2PC'        -> redeemed
全角+長音記号ー      Bad '7K30ーQ5XB'  -> False  Good '7K30ーQ5XBー2PC' -> redeemed
SMS からペースト      Bad '7K30-Q5XB​'    -> False  Good '7K30-Q5XB-2PC​'  -> redeemed
小文字・0 を o と打つ  Bad '7k3oq5xb'           -> False  Good '7k3oq5xb2pc'          -> redeemed
5 を S と見間違い     Bad '7K30QSXB'           -> False  Good '7K30QSXB2PC'          -> InvalidCouponCode(check_mismatch, position=None)
隣どうしの入れ替え     Bad 'K730Q5XB'           -> False  Good 'K730Q5XB2PC'          -> InvalidCouponCode(check_mismatch, position=None)
U が混ざる            Bad '7K30Q5XU'           -> False  Good '7K30Q5XB2PU'          -> InvalidCouponCode(bad_character, position=10)

同じコードを続けて使う
  m1: Bad -> True   Good -> redeemed
  m1: Bad -> False  Good -> already_yours
  m2: Bad -> False  Good -> used_by_other

迷った案と採らなかった理由

案判断理由(本問の実測・導出)切り替える条件
A 後輩案2 だけ出して終わる今日すぐ出す(第一手)。ただし終わりにしない成功率は Good と同じ 96.2%。Q2・Q3・Q4 は 1 つも直らない今回限りの施策なら、条件付き UPDATE だけ足して止めてよい
B 後輩案1(ハイフンだけ)不採用85.5%。全角・「ー」・ゼロ幅の 1 割強が残るなし
C Crockford mod 37不採用13.5% のコードの末尾が記号QR か PC のキーボードだけで打つ運用
D 自動訂正(5/S・8/B・2/Z・6/G を 1 文字入れ替え、通る候補が発行済みで 1 つなら受付)基本には入れない。2 週間測ってから「確認つき」で+2.8pt(96.0→98.9%)。損は 1 回の推測で試すコードが約 1.3 倍、他人のコードを受け付ける確率は 1 回約 6×10^-11(導出)check_mismatch が試行の 2% を超えたら「もしかして …?」と会員に押させる形で
E 見間違いうる文字をもっと除く(24〜28 文字)不採用1 文字 4.6〜4.8 ビットで同じ強さに 1 文字長くなる。Crockford の仕様に乗れない(Windows のプロダクトキーの 24 文字は広く知られた例・公式仕様は未確認)問い合わせが「5 と S」に集中していると分かったら
F RFC 4648 Base32不採用読み替え(O→0)の約束が仕様に無い既存が RFC 4648 で揃っている場合
G 集合外の文字を捨てて照合不採用別の発行済みコードに化けうる(変異 M4 で赤を確認)なし
H UUID・22 文字以上不採用紙から打たせると打ち間違いが文字数に比例して増える(未測定)QR・ディープリンクで配るとき

直した後に見るもの・止め方

見るもの出し方止める・戻す判断
理由別の失敗率reason・RedeemStatus を DataDog に(タグ: 配布チャネル)check_mismatch が 2% 超で案 D を検討。bad_character が多ければ文字と位置を見て _SEPARATORS を見直す
会員あたりの失敗回数会員 ID で数える1 会員 1 時間 10 回超で一時停止(レート制限部品へ)
used_by_other の件数同上家族共有なら仕様(1 世帯 1 枚か)の問題。急増は流出を疑う
CS の問い合わせタグ「クーポン使えない」後輩案2 の翌日に初日の 1 割以下にならなければ、仮定(区切り・全角)が外れている

ポイント解説

  1. 人が打つ識別子は「文字列」ではなく「規則を持つ値」: 使える文字・区切り・読み替え・検査文字を CouponCode の 1 か所に閉じれば、DB・画面・CS のどこにも規則が漏れない。Bad の strip().upper() は規則を 1 行で「分かったつもり」にしていた(第5章)
  2. NFKC は万能ではない: 全角英数と全角ハイフン・全角スペースは畳むが、「ー」・「−」・ゼロ幅文字は畳まない。区切りを明示的に除き、残る非 ASCII は upper() の前に拒否する('ı'.upper() は 'I')
  3. 効いた改善と効かなかった改善を分けて数える: 1 回目の成功率は正規化(後輩案2)で 96% に届き、検査文字は成功率をほとんど上げない。検査文字が効くのは「失敗した人に何と言えるか」と「自動訂正を安全にできるか」

実務への応用

  • 販促施策のレビュー項目にする: コードを配る施策は、企画の段階で「文字の集合・長さ・区切り・検査文字・配布チャネル(打たせるか、リンクか)」を決める。チラシの版下が出てからでは直せない
  • 同じ形の問題: 紹介コード、ギフトカード、店頭受け取りの引換番号、2 段階認証の回復コード、問い合わせ番号(CS が電話で聞き取る)
  • 会員の住所・氏名の正規化とは分ける: 名寄せの正規化は「同じ人を見つける」ため(誤統合が怖い)。クーポンコードは「規則の外の文字を拒否する」ため。方向が逆なので同じ関数を使い回さない
  • BigQuery 側: 理由別の失敗を coupon_redeem_attempts として流せば、チャネルごとの打ち間違い率が出せ、次の施策でコードの長さとチャネルを決める根拠になる

扱わなかった範囲(と理由)

範囲理由扱うなら
配布済み 8 文字コードの移行刷り直せない 20 万個が残るLegacyCouponCode を別の値オブジェクトにし、長さ 8 なら後輩案2 の正規化だけ(発展問題)
レート制限の実装コードの作りに絞った。入ってくる推測の制限はこのスキルでまだ扱っていない(4/27・6/29 は外向きの API 制限)会員 ID と端末で失敗回数を数えて一時停止
random の状態復元攻撃の実演random.choice は 1 文字 6 ビットずつで、復元に GF(2) の連立方程式が要る根拠は Python 文書の警告と、32 ビット出力 624 個から状態を復元できる既知の性質
実際の PostgreSQL での同時実行SQLite で同じ SQL を動かしただけ。Read Committed では 2 本目が 1 本目の確定を待って条件を評価し直す(PostgreSQL 文書)ので同じ結果のはずだが未測定psycopg で同じ Barrier のテストを流す
人の入力の実データすべて仮定。感度表で幅だけ見た2 週間ぶんの失敗入力の文字種統計で仮定を差し替える
スクリーンリーダー・アクセシビリティ範囲外コピーボタンとディープリンクを併置

一次資料(参照日 2026-10-12。ページ本文は今回開いていない。記憶と過去の参照に基づく)

  • Douglas Crockford, "Base 32" crockford.com/base32.html — 32 文字の集合、I・L→1、O→0 の読み替え、mod 37 の検査文字
  • UAX #15 "Unicode Normalization Forms" unicode.org/reports/tr15 — 「ー」・ゼロ幅を畳まないことは Python 3.14.7 で実測
  • UTS #39 "Unicode Security Mechanisms" unicode.org/reports/tr39 — confusables
  • Python 公式ドキュメント secrets/random の警告
  • "Luhn mod N algorithm"(Wikipedia)en.wikipedia.org — 見逃す隣接入替は全数で数えて 0Z の 1 組と確認
  • "Verhoeff algorithm"(Wikipedia)— Verhoeff (1969) の打ち間違いの分類と頻度
  • RFC 4648 rfc-editor.org/rfc/rfc4648

今日のまとめ

人が読んで打つコードは str ではなく、文字の集合・区切り・読み替え・検査文字を持つ値オブジェクトにする。ただし 1 回目の成功率の 9 割は正規化(NFKC+区切りの除去)だけで取り戻せる。検査文字の価値は、失敗した人に「打ち間違いです」と言えることと、自動訂正を安全にできることにある。

次のステップ

  • 発展問題: 配布済みの 8 文字コード 20 万個を残したまま、次の配布から Good 形式に切り替えよ。(a) parse が長さで旧・新を見分けるとき、新形式の 1 文字抜け(10 文字)が旧形式(8 文字)とぶつからないことを確かめ、(b) 旧形式にだけ後輩案2 の正規化をかける LegacyCouponCode を書き、(c) 両形式の理由別の失敗率が DataDog に分かれて出るタグを設計し、(d) 旧形式の受付を止める日を、1 日あたりの成功件数がいくつを下回ったら決めるか数で示せ
  • 参考: Crockford Base32、NFKC(UAX #15)、confusables(UTS #39)、Luhn mod N、Damm algorithm、compare-and-set、parse don't validate、mutation testing、property-based testing

自己評価(あとで記入)

自分の回答

気づき・メモ