概要
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 のまま)
設問
- Bad の問題点を 8 つ以上、Q1〜Q4 のどれに効くかと合わせて
- コードを文字列のまま扱わない値オブジェクト
CouponCodeと、引き換えのリポジトリ(Google スタイル docstring・インラインコメント・名前付き定数) - 入力の揺れ(全角・「ー」・ゼロ幅文字・小文字・O と 0)を例で、検査文字の性質を性質テストで固定。同じ約束のテストを Bad にも当てる
- 同じ合成入力で Bad・後輩案1・後輩案2・Good を比べ、正規化で済む部分と、文字の集合・検査文字でしか得られない部分を分けて示す
- 検査文字の方式(なし/単純和/Luhn mod 32/Crockford mod 37)を打ち間違いの型ごとの検出率で比べて選ぶ
- 変異テストでテストの強さを確かめる
- 迷った案と採らなかった理由、切り替える条件
制約: Python 3.12+、標準ライブラリのみ(テストは pytest・hypothesis)。DB は本番 Cloud SQL と同じ SQL を SQLite で動かす。印刷物は次回から変えてよい。レート制限は別部品(ここでは数値の根拠だけ)。
悪いコード (Before)
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
strip() は U+200B を落とさないヒント(段階的開示)
ヒント1 — 方向性
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)で打つと通らない | Q1 | Bad の失敗 44,525 件のうち 36,890 件(82.9%) 実測 |
| 3 | 全角・長音記号「ー」・全角空白が通らない。upper() は全角 a を全角 A にするだけ | Q1 | 非 ASCII を含む失敗 6,718 件 実測。区切り 6 種のテストが Bad で全部赤 |
| 4 | SMS からのコピーに混ざるゼロ幅文字が残る | Q1 | ''.strip() は '' のまま 実測 |
| 5 | 見間違えやすい文字(0/O・1/I・5/S・8/B・2/Z・6/G)を両方使う | Q1・Q2 | 見間違い・打ち間違いの失敗 888 件 実測 |
| 6 | 打ち間違いと未発行を区別できない(検査文字なし・bool 返却、第13章) | Q2 | 二度押しも False(demo) |
| 7 | SELECT してから UPDATE | Q3 | 同時押しテストで [True, True] 実測 |
| 8 | random(メルセンヌ・ツイスタ)で発行 | Q4 | 同じ種から同じ 3 個 ['0ADA-C97E', 'OZDK-O99O', 'E8U1-H8IF'] 実測。状態復元攻撃は未実演 |
| 9 | 8 文字・36 種(length=8 に根拠なし) | Q4 | 1 日 8,640 万回の推測で 期待 6.1 個 当たる 導出 |
| 10 | 一意制約・索引なし | Q4・運用 | 重複の期待値 0.0071 個 導出。照合が全件走査で、sim 専用に索引を張った |
正規化の流れと同時押し(SVG)
上段は正規化の順序。NFKC だけでは「ー」とゼロ幅文字が残る(実測)ので②で明示的に除き、③で非 ASCII を upper() より先に拒否する。下段は Q3 の原因と直し方。
Bad→Good 対比(要所)
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
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 回目の成功率 | 失敗したとき会員に言えること |
|---|---|---|
| Bad | 25.79〜26.32% | 「無効なコード」だけ |
| 後輩案1(ハイフンだけ除去) | 85.49〜85.57% | 同上 |
| 後輩案2(NFKC+区切り除去。集合・長さは Bad のまま) | 96.16〜96.24% | 同上 |
| Good | 95.96〜96.12% | 失敗 2,378 件中 2,373 件(99.8%)が「打ち間違い」と分かる。残る 5 件は 2 文字以上の誤りが偶然検査を通った「未発行」 |
| Good+自動訂正(採らなかった案 D) | 98.82〜98.90% | 同上 |
仮定を動かしたとき(sensitivity.py)
| 区切りも打つ | 全角 | 見間違い/文字 | Bad | 後輩案2 | Good | 自動訂正 |
|---|---|---|---|---|---|---|
| 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 32 | 100% | 9.74% | 10.78% | 87.49% | 100% | 32 |
| Luhn mod 32(採用) | 100% | 99.85%(見逃すのは 0 と Z の組だけ) | 0% | 97.81% | 100% | 32 |
| Crockford mod 37 | 100% | 100% | 100% | 92.55% | 100% | 37(* ~ $ = U) |
* ~ $ = 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^12 | 7.09×10^-8 | 0.071 個 | 6.1 個 |
| Crockford 32 × 8 | 1.10×10^12 | 1.82×10^-7 | 0.18 個 | 15.7 個 |
| Good(32 × 10) | 1.13×10^15 | 1.78×10^-10 | 0.0002 個 | 0.015 個 |
| 36 × 10(集合は Bad のまま伸ばす) | 3.66×10^15 | 5.47×10^-11 | 0.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 割以下にならなければ、仮定(区切り・全角)が外れている |
ポイント解説
- 人が打つ識別子は「文字列」ではなく「規則を持つ値」: 使える文字・区切り・読み替え・検査文字を
CouponCodeの 1 か所に閉じれば、DB・画面・CS のどこにも規則が漏れない。Bad のstrip().upper()は規則を 1 行で「分かったつもり」にしていた(第5章) - NFKC は万能ではない: 全角英数と全角ハイフン・全角スペースは畳むが、「ー」・「−」・ゼロ幅文字は畳まない。区切りを明示的に除き、残る非 ASCII は
upper()の前に拒否する('ı'.upper()は'I') - 効いた改善と効かなかった改善を分けて数える: 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