ニックネーム 360 件で長さ・切り詰め・同一性・日付を固める
目標
ニックネームの一覧で、UTF-8のバイト数・コードポイント数・人が見る文字数を別々に数え、バイト上限と文字数上限で壊れないように切り詰め、正規化と大文字小文字の畳み込みでユーザー名の衝突を数えます。続いて、UTCのイベントをユーザーの現地の日付でまとめ、夏時間をまたぐ「毎日09:00」の予約をUTCの瞬間に変換したあと、すべてを登録審査の関数1つにまとめます。
なぜ重要なのか
画面・サーバー・ストレージがそれぞれ異なる長さを数えると、「6文字まで」というルールが3つになります。バイトのまま切ると文字が壊れ、コードポイントのまま切ると、絵文字の家族が1人だけになったり、アクセントが外れたりします。同じ名前は、正準等価(NFC)と互換等価(NFKC)、大文字小文字の畳み込み(casefold)のうち、何を同じと見なすかを決めて初めて、1つと数えられます。日付も、「どこの今日か」を決めないと、日別の数字が隣の日付へ漏れます。そのため、採点ツールは、書いた数字だけを見るのではなく、あなたの関数を、用意された素材ではない入力で再実行して、基準の実装と突き合わせます。
用意するもの
/opt/fixtures/svccs/text/の下にあります。読み取り専用で使ってください。CSVはすべてUTF-8で、1行目がヘッダーです(csvモジュールでnewline=""を指定して読んでください)。
names.csv user_id,nickname 기존 회원 닉네임 360줄(완성형·조합형 한글, 결합 문자, 이모지, 전각, ß …)
signups.csv row,nickname 새 가입 신청 80줄. 빈 칸도 있다
users.csv user_id,tz 이벤트를 낸 사용자와 IANA 시간대 이름
events.csv event_id,user_id,ts_utc UTC 로 찍힌 이벤트. ts_utc 는 2026-03-07T23:40:00Z 꼴
params.json byte_limit · max_graphemes · schedule{zones, start, days, local_time}
このコードブロックの韓国語の説明は、順に、names.csvはuser_idとnicknameを持つ既存会員のニックネーム360行(完成形と分解形のハングル、結合文字、絵文字、全角、ßなど)、signups.csvはrowとnicknameを持つ新しい登録申請80行で空欄もあること、users.csvはuser_idとtzを持ち、イベントを出したユーザーとIANAタイムゾーン名であること、events.csvはevent_id・user_id・ts_utcを持つUTCで記録されたイベントで、ts_utcは2026-03-07T23:40:00Zの形であること、params.jsonはbyte_limit・max_graphemes・schedule(zones・start・days・local_time)を持つこと、を述べています。
文字のルール(このラボの簡略化したルール)
문자열을 앞에서부터 코드 포인트 하나씩 본다. 맨 앞 코드 포인트는 새 글자를 시작한다.
그 뒤로는 아래 가운데 하나라도 맞으면 앞 글자에 붙이고, 아니면 새 글자를 시작한다.
1. 지금 코드 포인트가 결합 표시(unicodedata.category 가 Mn 또는 Me)·변형 선택자
(U+FE00~U+FE0F)·피부색 수식자(U+1F3FB~U+1F3FF)·ZWJ(U+200D) 가운데 하나
2. 바로 앞 코드 포인트가 ZWJ
3. 한글 묶음 — 앞이 L 이고 지금이 L·V·LV·LVT / 앞이 V 나 LV 이고 지금이 V·T /
앞이 T 나 LVT 이고 지금이 T
L = U+1100~U+115F, V = U+1160~U+11A7, T = U+11A8~U+11FF,
완성형 U+AC00~U+D7A3 은 (코드 − 0xAC00) % 28 == 0 이면 LV, 아니면 LVT
국기(지역 표시 문자 쌍)·Prepend·SpacingMark 는 다루지 않는다(UAX #29 전체가 아니다).
このコードブロックの韓国語の説明は、順に、文字列を先頭からコードポイント1つずつ見ること、先頭のコードポイントは新しい文字を始めること、その後は、次のどれか1つでも当てはまれば前の文字に付け、そうでなければ新しい文字を始めること、1つ目は現在のコードポイントが、結合記号(unicodedata.categoryがMnまたはMe)・異体字セレクター(U+FE00からU+FE0F)・肌の色の修飾子(U+1F3FBからU+1F3FF)・ZWJ(U+200D)のいずれかであること、2つ目は直前のコードポイントがZWJであること、3つ目はハングルのまとまりで、直前がLで現在がL・V・LV・LVT、直前がVまたはLVで現在がV・T、直前がTまたはLVTで現在がTであること、Lの範囲はU+1100からU+115F、Vの範囲はU+1160からU+11A7、Tの範囲はU+11A8からU+11FF、完成形のU+AC00からU+D7A3は、(コード − 0xAC00) % 28 == 0ならLV、そうでなければLVTであること、国旗(地域表示文字のペア)・Prepend・SpacingMarkは扱わないこと(UAX #29の全体ではないこと)、を述べています。
ステップ
/root/svccs/text/lengths.jsonに、names.csvのニックネームについて、rows(行数)・utf8_bytes(UTF-8のバイト数の合計)・code_points(コードポイント数の合計)・rows_multibyte(バイト数 ≠ コードポイント数の行数)・max_utf8_bytes(1行の最大バイト数)を書きます。/root/svccs/text/textkit.pyにgraphemes(s)を作成します。上の「文字のルール」のとおりに分けた文字列のリスト(連結するとsになる)を返します。そして、/root/svccs/text/graphemes.jsonに、graphemes(文字数の合計)・rows_cp_ne_graphemes(コードポイント数 ≠ 文字数の行数)・longest_cluster_cp(1文字が持つコードポイント数の最大値)を書きます。- 同じファイルに
cut_bytes(s, limit)を作成します。コードポイントを分割せずに、UTF-8でlimitバイト以下になる、もっとも長い先頭部分です。params.jsonのbyte_limitをLとして、/root/svccs/text/cut.jsonに、byte_limit・rows_over_limit(バイト数がLを超える行数)・rows_naive_broken(s.encode("utf-8")[:L]を厳密にデコードするとUnicodeDecodeErrorが発生する行数)・utf8_bytes_after_cut(すべての行をcut_bytes(s, L)したあとのバイト数の合計)を書きます。 - 同じファイルに
fit(s, max_bytes, max_graphemes)を作成します。文字(ステップ2のルール)を丸ごとだけ入れて、文字数 ≤ max_graphemesとバイト数 ≤ max_bytesの両方を守る、もっとも長い先頭部分です。B =byte_limit、G =max_graphemesとして、/root/svccs/text/fit.jsonに、max_bytes・max_graphemes・rows_changed(fitの結果が原文と異なる行数)・split_by_cut_bytes(cut_bytes(s, B)の末尾が文字の境界でない行数)・split_by_cp_slice(s[:G]の末尾が文字の境界でない行数)を書きます。文字の境界 = 文字を先頭から連結していくときにできるコードポイントの位置(0とlen(s)を含む)です。 - 同じファイルに
username_key(s)=NFKC(casefold(NFKC(s)))を作成します。/root/svccs/text/unique.jsonに、異なるものの個数として、distinct_raw(原文)・distinct_nfc(NFC)・distinct_nfc_lower(NFCのあとlower)・distinct_nfc_casefold(NFCのあとcasefold)・distinct_key(username_key)、そして、collision_pairs(同じキーを持つ行どうしで作れるペアの数、キーごとのn·(n−1)/2の合計)・collision_groups(同じキーを持つuser_idのリスト、2個以上のものだけ)を書きます。 - 同じファイルに
local_date(ts_utc, tz)を作成します。events.csvの形式のUTC時刻を、IANAタイムゾーンtzの現地の日付"YYYY-MM-DD"に変換します。各イベントを、users.csvのそのユーザーのタイムゾーンでまとめて、/root/svccs/text/daily.jsonに、events(行数)・by_local_date(現地の日付 → イベント数)・by_utc_date(ts_utcの先頭10文字 → イベント数)・events_on_other_date(現地の日付 ≠ UTCの日付のイベント数)を書きます。 - 同じファイルに
daily_at(tz, start_date, days, hhmm)を作成します。start_date("YYYY-MM-DD")からdays日間、毎日現地のhhmm("09:00"の形式)の瞬間を、"YYYY-MM-DDTHH:MM:SSZ"(UTC)のリストとして返します。params.jsonのscheduleを使って、/root/svccs/text/schedule.jsonに、タイムゾーンごとに{"utc": 목록, "gaps_hours": 이웃한 두 순간의 실제 간격(시간, 넷째 자리) 목록, "short_days": 24 미만인 간격 수, "long_days": 24 초과인 간격 수}を書きます(コード内の韓国語は、順に「リスト」「隣り合う2つの瞬間の実際の間隔(時間、小数第4位)のリスト」「24未満の間隔の数」「24を超える間隔の数」を意味する語です)。 - 同じファイルに
check_signup(nickname, taken, max_bytes, max_graphemes)を作成し、下の「登録のルール」のとおりにsignups.csvを順番に審査して、/root/svccs/text/signups.jsonに、状態ごとの件数ok・taken・too_many_chars・too_many_bytes・emptyと、行の順序どおりのverdictsのリストを書きます(上限はbyte_limitとmax_graphemes)。
登録のルール
shown = NFC(nickname) 저장·표시는 NFC 로 한다
1. shown 이 공백(str.isspace)·ZWJ·규칙 1의 결합류로만 되어 있거나 비었으면 "empty"
2. graphemes(shown) 의 개수가 max_graphemes 를 넘으면 "too_many_chars"
3. shown 의 UTF-8 바이트가 max_bytes 를 넘으면 "too_many_bytes"
4. username_key(nickname) 이 taken 안에 있으면 "taken"
5. 아니면 "ok"
taken 은 처음에 names.csv 모든 닉네임의 키이고, "ok" 를 받은 신청의 키가 차례로 더해진다.
채점기는 taken 을 목록(list)으로 넘길 수도 있다 — in 으로만 쓰세요.
このコードブロックの韓国語の説明は、順に、shownはnicknameのNFCであり、保存と表示はNFCで行うこと、1つ目はshownが空白(str.isspace)・ZWJ・ルール1の結合類だけでできているか、空なら「empty」、2つ目はgraphemes(shown)の個数がmax_graphemesを超えれば「too_many_chars」、3つ目はshownのUTF-8のバイト数がmax_bytesを超えれば「too_many_bytes」、4つ目はusername_key(nickname)がtakenの中にあれば「taken」、5つ目はそうでなければ「ok」であること、takenは最初はnames.csvのすべてのニックネームのキーで、「ok」を受けた申請のキーが順に加えられること、採点ツールはtakenをリスト(list)として渡すこともあるので、inだけで使うこと、を述べています。
参考
- 採点ツールは
textkit.pyを読み込みます。ファイルを読んで結果を作るコードは、別のスクリプトかpython3 - <<'PY'に置いてください。 - バイト数は
len(s.encode("utf-8"))、コードポイント数はlen(s)です。正規化はunicodedata.normalize("NFC", s)、タイムゾーンはzoneinfo.ZoneInfo(tz)とastimezoneです。 - よくある間違い:
len()をバイト数として書くこと、バイトのスライスをerrors="replace"でデコードして、末尾にU+FFFDが残ること、NFCなしで重複を判定すること、UTCの日付で日別にまとめること、UTCの瞬間にtimedelta(hours=24)を足して予約を作ること、同じtzinfoのaware datetime同士を引いて、間隔が常に24時間だと書くこと。 - 出力物はセッションが終わると消えます。必要なら別に保管してください。
バイト数とコードポイント数は異なる
names.csvのニックネームの行数・UTF-8のバイト数の合計・コードポイント数の合計・2つが異なる行数・最大バイト数を、/root/svccs/text/lengths.jsonにrows・utf8_bytes・code_points・rows_multibyte・max_utf8_bytesとして書いてください。
Pythonのstrはコードポイントのシーケンスなので、len()はコードポイント数です。バイト数はs.encode("utf-8")の長さです。ハングルの完成形1音節は3バイト、ASCIIは1バイトです。
人が見る文字を数える
/root/svccs/text/textkit.pyに、「文字のルール」のとおりgraphemes(s)を作成し、/root/svccs/text/graphemes.jsonにgraphemes・rows_cp_ne_graphemes・longest_cluster_cpを書いてください。採点ツールが関数を直接呼び出し、結合文字・ZWJ・ハングルの子音字と母音字が混ざった変形した文字列で突き合わせます。
現在のコードポイントを前の文字に付けるかどうかだけを決めればよいのです。unicodedata.combining()は、異体字セレクター(U+FE0F)とZWJに0を返すので、それだけでは足りません。categoryと範囲を一緒に見てください。子音字(L)のあとの母音字(V)は付き、終声(T)のあとの子音字(L)は新しい文字です。
バイト上限で切り詰めても文字を壊さない
textkit.pyにcut_bytes(s, limit)を作成し、byte_limitを使って、/root/svccs/text/cut.jsonにbyte_limit・rows_over_limit・rows_naive_broken・utf8_bytes_after_cutを書いてください。採点ツールは、変形した文字列と複数の上限でcut_bytesを呼び出します。
UTF-8は、1つのコードポイントが1–4バイトで、続くバイトはすべて10xxxxxxの形です。コードポイントを1つずつ入れながらバイト数を加算し、超える直前で止めればよいのです。バイトのまま切ったあとでerrors="replace"で読むと、末尾にU+FFFDが残ります。
文字単位で切り詰める
textkit.pyにfit(s, max_bytes, max_graphemes)を作成し、/root/svccs/text/fit.jsonにmax_bytes・max_graphemes・rows_changed・split_by_cut_bytes・split_by_cp_sliceを書いてください。採点ツールは、変形した文字列と複数の上限でfitを呼び出します。
graphemes(s)で分けた文字を1つずつ入れながら、文字数とバイト数の2つの上限のどちらか一方でも超える直前で止めます。コードポイント単位で安全に切った結果でも、家族の絵文字を1人だけ残したり、アクセントを切り離したりすることがあります。その数がsplit_by_cut_bytesです。
同じ名前を決める
textkit.pyにusername_key(s) = NFKC(casefold(NFKC(s)))を作成し、/root/svccs/text/unique.jsonにdistinct_raw・distinct_nfc・distinct_nfc_lower・distinct_nfc_casefold・distinct_key・collision_pairs・collision_groupsを書いてください。採点ツールは、username_keyを変形した文字列でも呼び出します。
NFCは、完成形とハングルの分解形を揃え、casefoldはßをssに畳み込み、NFKCは、全角文字・合字・ケルビン記号のような互換文字を揃えます。ルールを1つ重ねるたびに、異なるものの個数が減るはずです。ペアの数は、同じキーn個ごとにn·(n−1)/2です。
UTCのイベントを現地の日付でまとめる
textkit.pyにlocal_date(ts_utc, tz)を作成し、events.csvをユーザーのタイムゾーンの日付でまとめて、/root/svccs/text/daily.jsonにevents・by_local_date・by_utc_date・events_on_other_dateを書いてください。採点ツールは、別のタイムゾーン・日付の変形したイベントでlocal_dateを呼び出します。
ts_utcをUTCのaware datetimeとして読み(末尾のZがUTCです)、astimezone(ZoneInfo(tz))で移してから.date()を見ます。ソウルはUTCより進んでいるので、UTCの日付でまとめると、ソウルの早朝が前日に回ります。
毎日09:00の現地の予約をUTCに変換する
textkit.pyにdaily_at(tz, start_date, days, hhmm)を作成し、params.jsonのscheduleを使って、/root/svccs/text/schedule.jsonに、タイムゾーンごとにutc・gaps_hours・short_days・long_daysを書いてください。採点ツールは、別のタイムゾーン・期間でdaily_atを呼び出します。
最初の瞬間をUTCに変換してから24時間ずつ足すと、夏時間の切り替えのあとで、現地時刻が1時間ずれます。現地の日付ごとにhhmmを新しく作って、UTCに変換してください。間隔は、UTCの瞬間どうしを引きます。同じZoneInfoを持つaware datetime同士を引くと、tzinfoが無視されて、常に24時間になります。
登録審査を1つの関数にまとめる
textkit.pyに、「登録のルール」のとおりcheck_signup(nickname, taken, max_bytes, max_graphemes)を作成し、signups.csvを順番に審査して、/root/svccs/text/signups.jsonにok・taken・too_many_chars・too_many_bytes・empty・verdictsを書いてください。採点ツールは、変形した申請とtakenのリストでもcheck_signupを呼び出します。
判定の順序がルールの一部です。バイト上限は、NFCに変換したあとで測ります。分解形で来たハングルは、原文のまま測ると3倍になります。受理した申請のキーをtakenに加えないと、同じ日に入った2つの申請が、どちらも通過します。