<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>3-shake Engineers' Blogs</title>
        <link>https://blog.3-shake.com</link>
        <description>3-shake に所属するエンジニアのブログ記事をまとめています。</description>
        <lastBuildDate>Mon, 18 May 2026 09:27:47 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>ja</language>
        <image>
            <title>3-shake Engineers' Blogs</title>
            <url>https://blog.3-shake.com/og.png</url>
            <link>https://blog.3-shake.com</link>
        </image>
        <copyright>3-shake Inc.</copyright>
        <item>
            <title><![CDATA[おい、要件を殺すな]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/18/100915</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/18/100915</guid>
            <pubDate>Mon, 18 May 2026 01:09:15 GMT</pubDate>
            <content:encoded><![CDATA[はじめに書き終えた要件は、書き終えた瞬間から古び始める。古びた要件と新しい要件を、AIは同列に扱う。「置き換え済み」と書いた印は、AIにとってはただの文字列でしかない。要件を言葉にする話を第1部で、要件を動くものに散らす話を第2部で書いてきました。発見し、言葉にし、コードと型とテストに焼き込む。ここまでで、要件はようやく実装の現場で動き始めます。第1部で扱ったのは「書かれずに死ぬ要件」、第2部で扱ったのは「書かれたまま読まれずに死ぬ要件」でした。第3部で扱うのは、最後の死、書かれて動いていたのに時間に殺される要件です。syu-m-5151.hatenablog.comsyu-m-5151.hatenablog.com書いた瞬間が要件のピーク、と言える時代もあったのだと思います。ドキュメントに書いて、合意して、実装する。そのまま長く動かす。たぶんそれは、要件があまり変わらない世界の話で、現代のソフトウェアにはほぼ当てはまらない気がしています。要件は時間の中で変わります。ビジネスが変わり、技術が変わり、ユーザーが変わる。一度書いた要件をそのまま守っていると、いつの間にか「もう必要のないことを律儀に守っている」状態になる。逆に、何でも書き換えていいと思うと、組織として何を約束しているのかが分からなくなる。どちらに振っても、たぶん事故が待っています。ここで時間軸の問題が乗ります。要件には「いつ書かれた」「いつから有効」「いつ置き換わった」という時間の層が常に付随していて、これを管理しないと事故の方向が決まりません。そしてAIには、この時間軸が見えません。コンテキストに入れた古い要件と新しい要件を、AIは同列に扱う。「置き換え済み」という印を付けても、AIにとっては単なる文字列です。人間が時間軸を管理しないと、AIは平気で過去の決定を採用してきます。第3部は、要件を死なせない話です。書いて終わりにせず、書きっぱなしのテストにもしない。毎日読まれ、毎日検証され、必要なら毎日書き換わる。要件を生き物として扱う規律。要件は決定の蓄積であること、AIには時間軸が見えないこと、要件を回し続ける運用、人間に残る責任、そして実務で守るべき最低ラインについて、順番に書いていきます。このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。要件は決定の蓄積、文脈と理由を残す要件を「達成したいゴールのリスト」として見るのが普通の捉え方ですが、もう1つの見方があります。要件は、これまでに下してきた決定の集合体だという見方です。「商品検索では在庫がある商品だけを返す」というのは、ある時点で「在庫切れ商品を含めない」という決定を下したことの記録です。決定の前段では、別の選択肢もあった。在庫切れ商品にも「再入荷予定あり」と表示して残す案、人気度順に並べた上で在庫切れを末尾に押し下げる案、検索結果には残すが購入導線を絞る案。これらの選択肢の中から、「在庫切れは含めない」という1つを選んだ。それが要件として残っている。要件文を読むと、結論だけが見えます。選択肢も、根拠も、捨てた選択肢の理由も、見えない。これが、要件文書の弱点です。書かれた結論を維持するか変えるかの判断が、後からできなくなる。なぜそう決めたかを誰も覚えていないからです。半年後に「なぜこれを選んだか」と聞かれて、口を開いたまま画面をスクロールする時間が10秒は続く。資料を漁って、3分後に「たぶんこの理由だったと思います」と答える。この「たぶん」が、要件文書の本当のコストだ。結論だけが残り、選んだ理由は3分かけても拾えない。決定の記録を真面目に書き始めたのは、ある決定の理由を半年後に思い出せなかった経験からです。「なぜRedisではなくMemcachedを選んだか」と聞かれて、私とチームの誰も答えられなかった。理由はあったはずなのに、残っていなかった。判断材料が消えているせいで、移行を再考すべきかの議論が空転しました。あれ以来、要件と並行して決定の記録を残しています。判断は要素に分解して見る、最も弱い要素が全体を律速する決定を残し始めて、もう1つ気づいたことがあります。判断の質は、要素に分解して見たほうが扱いやすい、ということです。判断の現場には、いくつかの要素が並んでいます。何を問題として枠取ったか。選択肢を何本並べたか。どんな情報を握って選んだか。何を大事にして選んだか、つまりどの軸を中心に据えて重んじたか。そこから結論への論理に飛躍はないか。決めた後に実行へコミットしたか。学術の世界には Decision Quality というこれを体系化した枠組みがありますが、固有名詞よりも、全体の質は最も弱い要素で決まるという見方のほうが現場では効きます。情報が分厚くても、選択肢を1つしか並べていないなら、出てくる結論は1つに収束します。論理が精緻でも、何を大事にしているかを口にしていないなら、収束した先がチームの本音と合わない。決めたつもりでも実装に降りていないなら、それは決定ではなく感想です。判断の中で最も薄い要素が、判断全体を律速する。コードの依存関係と同じ性質です。そしてもう一段、自分の中で解像度が変わった視点があります。良い決定と良い結果は別物だ、というものです。当時の情報の中で誠実な判断を下しても、結果が悪く転ぶことはあります。逆に、ろくに考えなかった判断が運良く成功することもある。記録に残すべきは決定の中身であって、結果の良し悪しではない。結果が悪かったときに「決定そのものが間違っていた」と短絡すると、当時は妥当だった判断の作法まで一緒に捨ててしまう。次に同じ場面に立ったとき、何を学んでいいか分からなくなる。決定を残す本当の理由は、結果の運不運から、判断の作法を切り分けて守ることだ。これを言葉にできるようになるまで、過去の自分のしくじりを2、3度同じ顔で繰り返した記憶があります。これは要件運用にも効きます。要件文だけだと、結論の良し悪しを結果から逆算して評価しがちです。決定として残しておけば、結果と切り離して「あの判断のどの要素が薄かったか」を後から検証できる。要素分解は、判断を後から学習可能にするための補助線です。決定として書く、文脈・選択肢・結論・帰結私が要件と並行して書くのは、こういう構造の記録です。文脈：その決定が必要になった背景。何に困っていて、何を達成したかったか選択肢：検討した代替案。それぞれのメリットとデメリット決定：採用した選択肢。なぜそれを選んだか帰結：その決定が他に何を縛るか。今後変える場合に何が動くか要件文だけだと「在庫がある商品だけを返す」で完結します。決定として書くと、こうなります。在庫切れ商品の扱いについて、3案を検討した。再入荷時の通知機能はまだ未実装で、検索結果に残すと購入後のキャンセル率が上昇する懸念があった。当面は検索結果から除外する。再入荷通知が実装されたら、この決定は再考する。この差は、後から振り返るときに決定的です。「在庫切れを除外する」という要件を変えたい、という話が半年後に出てきたとき、要件文しかなければ「なぜそう決めたのか」を再現するのに膨大な時間がかかります。決定として書いてあれば、「再入荷通知が実装されたなら、この前提条件が変わったので、再考できる」と即座に分かる。変更コストが下がる。そして、決定の記録は不変にします。一度書いた決定は、過去に戻って書き換えない。新しい状況で別の決定を下したいなら、新しい決定として記録し、古い決定を「置き換えられた」と印を付ける。古い決定は消さない。なぜ消さないのか。当時の判断は、当時の文脈の上では正しかったからです。再入荷通知がない時点で、在庫切れを表示しないという決定は妥当だった。後年に技術が進み前提が変わって別の決定を下したとしても、過去の決定は「間違っていた」とはならない。文脈の違いから別の正解だった、というだけ。過去の決定は、過去の文脈を保存する歴史資料です。ドキュメントが腐って読まれなくなる主な理由は、書かれた当時の文脈が抜け落ちるからです。文脈を一緒に書き、文脈ごと残す規律があれば、ドキュメントは風化に耐えます。そして、決定として書き残すのは「やる」だけではありません。意図してやらない、と決めたことも同じ重さで記録します。「マルチテナントは現バージョンでは作らない」「論理削除は導入しない」「リトライは現状の3回から増やさない」。書かないと、半年後にAIや新しいメンバーが、書かれていない領域を「未対応の隙間」と解釈して埋めてきます。良かれと思って入れた論理削除フラグが、要件にないキャパシティと運用負荷を持ち込む。やらない決定は、やる決定よりも消えやすい。形がないからです。だから明示的に書き残す。「これは意図してやっていない」は、未来の自分とAIに対する境界線で、書き残されない境界はかならず侵食されます。決定は実装されて初めて決定になるもう1つ重要な視点があります。実装されない決定は決定ではない、というものです。会議で「こうしよう」と合意しただけ。ホワイトボードに描いただけ。議事録に書かれただけ。これは全部、まだ決定ではありません。願望です。コードに反映され、本番で動き、ユーザーが体験して初めて、それは決定になる。これは要件にも当てはまります。要件文書に書いただけの要件は、まだ要件ではない。テストとして書かれ、CIで毎回検証され、本番のコードがその通りに動いて、初めて要件として機能します。書かれた瞬間ではなく、動く瞬間に要件は要件になる。ドキュメントへ書いた決定は、動くものに焼き込むまで未完成です。ドキュメントは準備段階の記録、動くものが本番、という順序を間違えない。この「動くものに焼き込む」発想は、自分の中で1つの装置のイメージにまとまっています。ある決定が成立しているかどうかを、機械が客観的にチェックして鳴らす仕掛け、というのが私の持っている形です。テストとは限りません。CIで動くlint、本番のメトリクス監視、ログから違反を拾う小さなスクリプト、アーキテクチャテスト、どれもこの仕掛けの一種です。重要なのは、決定が「人が思い出して気をつける」領域から、「壊れたら自動で気づく」領域へ移ることです。自分に問いかけたい。ADRに書いた決定は、実装の現場でどれくらい参照されているか。私は即答できません。書いた決定の何割が、本当にコードを書く瞬間に開かれているか。たぶん、半分も開かれていない。書かれた決定は、参照されない決定でもある可能性が高い。だから決定の重要なものは、最終的に機械が鳴らす仕掛けへ焼き込むしかない。「動く瞬間に要件は要件になる」と書いてきたのは、私が現場で身体で覚えてきた感覚です。決定として要件を扱うことは、要件に責任の主体を持たせることでもあります。要件文だけだと「誰がそう決めたか」が霞みます。決定として書くと、決めた人、決めた日付、文脈、根拠が紐づきます。これがあると、要件は議論可能なものになる。議論できる要件だけが、組織の中で生き続けます。ベースラインは凍結ではない決定を蓄積するときに気をつけたいのが、ベースラインと凍結を混同しないことです。ベースラインは、「ある時点で関係者全員が合意した状態」の記録です。「このリリースのスコープはここまで」「このイテレーションではこの機能を出す」。合意の固定点を作ることで、混乱なく実装に進める。ただし、ベースラインを作ることは、変更を禁じることではありません。ベースライン化された後でも、状況が変わったら変更できる。変更にあたっては変更管理プロセスを通す。誰がその変更を承認するか、影響範囲は何か、コストはどう変わるか、を明示的に検討する。プロセスを通った変更は正当です。プロセスを迂回した変更が混乱を生む。私はかつて「ベースラインを引いた以上動かさない」を運用ルールにしていた時期がある。動かさないことを正義だと思っていた。半年で、ベースラインの周りに迂回路ができ、誰もベースラインを見なくなった。動かさない規律は、動かす規律を持たない組織では、ただの飾りに変わる。プロセスは、変更を許すために要る。止めるために要るのではない。ベースラインの意味は「この時点で全員が同じ絵を見ていた」ことの記録であって、「ここから動かすな」の宣言ではない。この区別ができていないと、要件はすぐに硬直します。変更が来たときには、見積もりも合わせて見直す必要があります。「要件を10個追加しても納期と費用は変わらない」という錯覚に陥りやすいですが、これは成り立ちません。要件が動けば、見積もりも動く。見積もりを動かさず要件だけ動かすと、最後に整合性が取れず崩れる。変更の影響分析、一行の要件変更が何に波及するか要件が変わる、と一言で言いますが、現場で実際に起きるのは「一行の要件変更が、何箇所のコード・テスト・契約・データに波及するか」を見極める作業です。「在庫切れ商品も検索結果に含めたい」という要件変更が来たとします。一見、検索ロジックに小さな修正を入れるだけに見えます。実際に動くのはもっと広い。直接の実装が動くのは、当然です。検索クエリのフィルタ条件、結果のソート順、表示するメタデータ。問題はその外側で、前提を共有していた他の要件が連鎖します。「購入導線で在庫切れを止める」「カートに入れる時の在庫チェック」「決済時の最終確認」。これらは「在庫切れは結果に含めない」を前提に組まれてきています。前提が変われば、これらの要件も再検証が必要になります。テストも軒並み書き直しになります。「在庫0の商品が結果に含まれない」を検証していたテストは、片っ端から書き換え。新しい振る舞いを確認する新規テストも要る。さらに周辺の契約——検索APIを呼んでいる外部システム、分析基盤に流れているログ、レポート生成のクエリ——が、暗黙のうちに「在庫切れは含まれない」を期待していた場合、変更すれば壊れます。技術の外まで波及することもあります。営業は新しい振る舞いを顧客に説明する必要があり、サポートはFAQを更新し、UXは導線を見直す。これは技術的な変更ではないが、要件変更の波及範囲には含まれます。この影響範囲の見積もりが、要件変更の本当のコストです。「コードを1行変えるだけ」と思っていた変更が、20箇所の調整を要求してくることがあります。逆に、「大きな変更」に見えた要件が、実は隔離された範囲で済むこともある。要件変更の重みは、そのまま実装したときの工数では測れず、波及の幅で測るのが正確です。影響分析は、変更を承認する前にやる作業です。「この変更を入れたら、何が動くか」を少しでも考えてから、決定を下す。短い時間で見える範囲は限られますが、考えなければ本当にゼロです。そして、影響範囲が大きい変更には、バッファを織り込む判断が要ります。要件の追加や変更がイテレーションに入ったら、対応用のキャパシティを最初から確保しておく。これがないと、最初の小さな変更で、計画全体が吹き飛びます。「変更を歓迎する」というのは「無計画に詰め込む」とは違う。変更を吸収するキャパシティを設計に組み込んでおくことで、はじめて変更を歓迎できます。要件を時間の中で生かすには、変更の影響分析と、変更を吸収する余地の設計が、両輪で要ります。これは設計の仕事ではなく、要件運用の仕事です。設計判断の失敗は、たいてい主軸の取り違え変更の影響分析の話を書きながら、もう1つ気づいたことがあります。時間の中で要件を生かそうとすると、必ず最初の判断の質に戻ってくる、ということです。最初に主軸を取り違えた要件は、半年後に変更が来たとき、影響分析しても何が壊れるかが見えません。なぜ壊れるかが、最初から定まっていなかったからです。だから時間軸の話に深く入る前に、判断そのものの構造を一度書いておきます。決定を要素に分解して残す運用を続けていると、もう1つ気づくことがあります。現場で繰り返し起きる失敗の多くは、症状こそ違うが、構造は同じだ、ということです。並べてみるとバラバラに見えます。私自身、一度やりました。深夜の障害対応で根本原因を追っているはずの議論が、いつの間にか「これを機に基盤ごと刷新しよう」に流れていた夜があります。誰も止めなかった。止めなかったのは、その瞬間のチームに「いま守るべきは復旧だけだ」と握れる軸が、誰の手にも残っていなかったからです。同じ取り違えを、自分以外でも何度か見ています。基幹システムの刷新なのに短期の実装容易性だけで技術を選んだ案件。セキュリティの問題なのに UX の都合で判断を曲げた案件。PoC なのに本番運用の品質を要求してしまい、PoC が PoC のまま終われなくなった案件。本番機能なのに PoC の速度感で書かれて、半年後に書き直しになった案件。動くものができてから権限と監査と冪等性を後付けで足し続け、最後に層が剥げてしまった案件。技術領域も場面も違うので、それぞれ違う失敗に見えます。ところが、判断を要素に戻して見ると、全部同じ顔をしています。いま重んじるべき軸と、実際に重んじている軸が、ズレている。前者は第2部で「主軸」と呼んだもの——その場面で中心に据える1本の軸——と同じです。失敗の中心は、主軸の取り違えです。短期効果を主軸にすべき場面で長期保守性を取る。組織の習熟度を主軸にすべき場面で技術的理想を取る。整合性を主軸にすべき場面でその場の動きやすさを取る。症状が個別の名前を持っているだけで、診断は1つに収束します。これに気づくと、現場で起きる失敗の見方が変わります。標準解があるのに独自実装に逸れる、変化点が観測されてもいない段階で先回りして抽象化を入れる、責務本体に書くべき判断を近場の便利な場所へ書き散らす——こういう症状に名前を付けて覚えるのは悪いことではありません。ただ、名付けの目的は名前を呼ぶためではない。自分がいまどの軸を主軸にして判断しているかを、意識化するためです。名前を呼べても、軸を取り違えたままなら、別の症状で同じ失敗を繰り返します。優れたエンジニアは、これを実務の中で身につけてきました。設計レビューと OJT で、先輩の背中を見て、判断の根拠を盗む。ただしそれは暗黙知として個人に蓄積されたもので、明示的な教材も育成の道筋も整っていなかった。Vibe Coding の時代に問題が立ち上がるのは、この継承構造そのものが崩れるからです。背中を見て学ぶ環境が崩れ、エージェントが代わりに大量のコードを書いてくる。エージェントは主軸を持ちません。だから、本来の主軸から外れた判断をしているとき、自分が外れていることを自覚しない。代替案もメリデメも丁寧に並べてくる。並べるが、どれを採るべきかの軸を、自分の中で選んでいない。観測される失敗の総量は、AI 以前から現場にあったものと種類は変わっていないと思います。同じ取り違えが、同じ顔のまま、同じ場面で起きている。変わったのは、件数と頻度です。たぶん桁が1つ違う。人間が主軸を意識できないと、エージェントは黙ってその空白を「平均的にもっともらしい軸」で埋めてきます。多くの場合、その平均はあなたのプロジェクトの主軸ではありません。人間側に必要なのは、失敗パターンの名前を暗唱できることではなく、いまの場面でどの軸が主軸として正しいか、選んだ軸でいいのか、を立ち止まって問い直す習慣です。決定の記録に「何を大事にしてこれを選んだか」を一行書く運用は、結局この問いを自分に強制するためにあります。書けないなら、主軸が決まっていない。主軸の取り違えは、書く前に発見しないと、コードに落ちた瞬間に取り戻せない。コードを直すコストは要件文を直すコストの比ではないからです。そしてもう1つ書いておきたいのが、主軸は1つに絞る代わりに、満たすべき水準を持つ二次条件は別に立てる、という運用です。主軸を「短期に動かす」に置いた場面でも、整合性や安全性は二次条件として最低水準を切らない。主軸を「長期保守性」に置いた場面でも、出荷の納期は二次条件として下限を持つ。主軸を立てる議論と、二次条件を切らない議論は別物です。1軸に絞ったから他を捨てていい、にはならない。この区別を持たないと、主軸を意識化した瞬間に、別の軸が無防備に置き去りにされます。AIには時間軸が見えない、運用上の注意決定を蓄積していくと、すぐに次の壁にぶつかります。私が現場で何度かハマって、やり直しを食らった種類の失敗です。AIは、与えられたコンテキストの時間軸を、正しく理解できない。一文で書くとこれだけですが、現場で食らうとそこそこ痛い。AIは「いつの情報か」を判断しないAIに渡すコンテキストの中には、新しい情報と古い情報が混在します。CLAUDE.md / AGENTS.md の最新セクションと半年前の記述、現役のskillと放置されたskill、最新の決定記録と2年前の決定記録、最新のコードと削除し忘れたコメント。これは時系列上で別物ですが、AIにとっては「同じコンテキスト」として届くだけです。AIは「これは古い記述、これは最新」を区別しません。学習データの中で時系列の判断を学んでいる側面はありますが、目の前のコンテキストに対しては、すべての情報を現在有効な事実として扱います。日付が書いてあっても、その日付がいつより前なら無効、というルールを自動で適用してくれるわけではありません。何が起きるか。古い決定と新しい決定が、AIの中で衝突する。例えば、決定記録に「APIの応答形式はJSONとする」と書かれていて、半年後に「GraphQLに移行した」という新しい決定が追加されたとします。人間なら新しい決定を優先します。AIに両方を渡すと、AIは「JSONを返す実装」と「GraphQLを返す実装」のどちらをも、もっともらしく書ける状態になります。コンテキスト全体を再評価して、整合性のある実装を選ぶ、という判断は確実には期待できません。「置き換え済み」は人間用の印で、AIには伝わりにくい決定記録には「置き換え済み」というステータスを付ける慣習があります。古い決定を残しつつ、新しい決定で上書きしたことを示す印です。これは人間の運用文化として整備されてきたもので、人間が決定記録を順に読めば、ステータスを見て「これは古い」と認識します。AIはこのステータスを「見ている」が「重みづけている」かは別問題です。コンテキストとして全文を渡されたとき、「置き換え済み」の印は単なる文字列にすぎません。AIの注意を強く引くトークンではない可能性が高い。むしろ、決定記録の内容そのもの（具体的な技術選択、パターン、コード例）のほうが目立ちます。結果として、「置き換え済み」なのに、AIがその決定を採用した実装を返してくる、という事故が起きます。これは現場で何度か遭遇しました。AI自身が持ち込む「学習時点の常識」時間軸の話で私が見落としていたのが、AI自身が外から持ち込んでくる時間軸です。AIは目の前のコンテキストだけでなく、学習したときに頻出していたパターンを、強い前提として持ち込みます。これに気づいたのは、社内独自の規約を持ったコードベースで、何度同じ補正を入れてもAIの出力が世間の平均値に戻ってくる現象を、半年くらい繰り返してからでした。社内でRedisをキャッシュ用途に限定する規約があっても、AIはセッションストアに使う典型例を平気で書いてくる。社内固有のAPI命名規約を渡しても、世間で見慣れた getXxx setXxx の形に寄せて返してくる。型に押し込んで弾いても、説明し直すと1回は直るが、別ファイルでは元に戻っている。これは古い情報がコンテキストに混じる話ではなく、AIの中にある「世間の標準像」が、目の前の社内仕様より優位に出てきてしまう話です。たとえば、Rustで「メールアドレスはニュータイプで包み、エラーは thiserror で型として定義する」を社内規約にしているとします。AIに新しいユーザー作成関数を頼むと、しばらくはこういうコードを返してきます。// AIが「世間の平均」で出してくる側pub fn create_user(email: String, name: String) -> Result<User, Box<dyn std::error::Error>> {    if !email.contains('@') {        return Err("invalid email".into());    }    Ok(User { email, name })}String を引き回し、Box<dyn Error> で雑に握り潰し、バリデーションは関数内に流す。世間の入門記事で見慣れた書き方です。社内規約に揃えるとこうなる。// 社内規約: 不正な状態を型で表現不可能にし、エラーは静的に分類するpub fn create_user(email: Email, name: UserName) -> Result<User, UserError> {    Ok(User { email, name })}#[derive(Debug, thiserror::Error)]pub enum UserError {    #[error("invalid email: {0}")]    InvalidEmail(String),}Email 型のコンストラクタで @ チェックを弾き、関数本体には ? も if も残らない。エラーは enum で列挙され、リトライ判定や HTTP ステータス変換が静的に書ける。この設計は私たちの規約として何度も繰り返してきたものです。私は最初、AIが String 版で返してくるのを、コンテキストの渡し方の問題だと思っていました。AGENTS.mdの記述が弱いのか、サンプルが足りないのか、と。コンテキストを厚くしても、しばらく経つと同じ場所で同じ侵食が起きる。指摘して直しても、別ファイルで同じことが起きる。半年かけて辿り着いた結論は、コンテキストの鮮度は管理できても、AI内部の「学習時点の常識」までは剪定できないでした。priorsとはこういう侵食の仕方をします。そこから今やっているのは、3つです。社内固有の決定を「これは世間の標準と違う」と明示してコンテキストの先頭に置く。社内サンプルを増やして数で殴る。レビューで「世間の平均に寄った書き方」を型・lint・命名チェックで弾く。3つともコストはかかるし、3つやっても完全には消えません。それでも放っておくと、AIが書いたコードはじわじわと「世間の平均」へ収束していきます。世間の平均は、たいてい私たちのプロジェクトの主軸ではありません。第2部で書いた主軸の取り違えは、人間側だけでなく、AIの priors からも侵入してきます。コードの「削除し忘れた残骸」がAIを混乱させるもう1つ厄介なのが、コードベースに残った過去の残骸です。リファクタリングの結果、もう使われていない関数が残っている。ABテストで採用されなかった分岐がコメントアウトで残っている。古いライブラリの呼び出しが、新しいラッパーと並行して残っている。これは人間のコードリーディングでは「ああ、削除し忘れか」と読み飛ばせます。AIにとっては、現役のコードと残骸が同じ重みで参照される。AIに「この機能を実装して」と頼んだら、最新のパターンではなく、削除し忘れた古いパターンを真似してくる。これも実際にあった失敗です。一度、ある決済まわりのコードで、3年前に廃止されたはずの旧クライアントのラッパーがファイルの末尾に居残っていた。コメントで「TODO: remove after migration」と書いてあって、誰もそのTODOを消さなかった。AIに「ここに新しい決済プロバイダを追加して」と頼んだら、新しいプロバイダのアダプタを、その旧ラッパーのインターフェースに合わせて書いてきた。手元では動いた。ステージングで例外が出て、ようやく気づいた。失った時間は半日。読み飛ばせる残骸は人間にとっての残骸であって、AIにとってはコードベース上の現役だ。あの日以来、TODO: remove で残った行は、PRレビューで必ず指差し確認するようにしました。残骸は気が向いたら消すものではなく、期日を切って消すものだと、半日かけて学びました。不確実性は自動的に縮まらない時間軸の話で、もう1つ大事なのが不確実性は時間が経てば自動的に減る、ではないということです。プロジェクトが進めば、要件の輪郭が固まり、見積もりの幅も縮まる、と思いがちです。実際には、意識的に縮める作業——要件の明確化・設計の詰め・意思決定の積み重ね——をしないと、不確実性は広がったままです。プロジェクトが終盤に来ても、初期と同じ精度の見積もりしか出せません。AI時代に注意したいのは、実装が速いから不確実性が縮んだ気になる錯覚です。コードはどんどん出てくる。動いているように見える。でも、要件が曖昧なままなら、その実装が本当に正しいかは分からない。実装の速さは、要件の明確さの代わりにはならない。私は一度、これに正面からやられました。あるプロジェクトで、開始3週間でPRが40本マージされ、ダッシュボードは緑、デモも動いていました。チームの空気は「もう半分終わった」でした。要件のうち、誰がどの画面で何をしたいのかという肝心の部分は、3週間前と一語も変わっていなかった。動くものが増える速度と、要件の輪郭が固まる速度は、独立した変数です。前者を後者の代理指標に使った瞬間、見積もりの幅は縮まったように見えるだけで、実態は広がっています。プロダクションに出す前夜になって、初めて要件の幅に再会する。あの夜は、緑のダッシュボードを眺めながら、これは進捗ではなくただの動きだと、ようやく書き直しました。運用上の対策で、時間軸を意識的に管理する時間軸を扱えないAIに対して、人間側でできることをいくつか書いておきます。私が現場で守っているラインです。まず、書いた文書には有効期限を付けます。要件、決定記録、設計メモ、運用手順。書いた瞬間に「いつ時点で有効か」を一行入れる。一定期間経ったら見直す。長く手付かずなら、内容が古い疑いを持つ。古い疑いがある文書は、AIへ渡す前に剪定する。廃止された情報は、規制対象でない限り物理削除に倒します。「置き換え済み」という印に頼らない。新しい決定が下されたら、古い決定はリポジトリから削除する。履歴は git に残るので、必要なら遡れる。ただし、AIに渡される現在のコンテキストには含めない。現在のコンテキストには現在有効な情報だけを置く——というのが原則です。ただし、規制の強い領域では話が変わります。医療、金融、安全に関わる制御系などでは、過去の決定そのものを保管しておくこと自体が監査要件になります。物理削除を選ぶ前に、自分の領域がそのどちら側か確認してください。削除できない場合は、現在有効な決定だけを別ディレクトリへ分ける運用に倒します。古い決定は decisions/archive/ のような場所に移し、decisions/ には現役だけを置く。AIには decisions/ だけを参照させ、archive は人間の調査用に取っておく。剪定の対象は、決定記録だけではありません。CLAUDE.md / AGENTS.md と skill も書きっぱなしにせず、定期的に内容が現状と一致しているかを点検する。古いルールは消す。新しいルールを追加するときは、矛盾するルールがないかをチェックする。剪定のコストを織り込んで設計する——これを最初から運用に組み込まないと、腐ったまま放置されたコンテキストが、AIへ恒常的に汚染を流し込みます。コードベース側にも残骸を残さない。未使用の関数、未使用のファイル、コメントアウトされた古いロジック。これらは CI で検出して、PR 時点で削除を強制する。AIが誤読する材料を、コードベースから減らす作業です。要件文と決定の記録もファイルとして分け、AIには要件文を、決定の記録は人間の議論用に置く。両方を1つのコンテキストに混ぜない。そしてもう1つ、触れない領域を明示する規律。コンテキストの先頭に、「このタスクで触れてはいけないファイル」「このリファクタで意図的に変更しないモジュール」を書く。書かないと、AIは「ついでに直しておきました」と意図外の変更を混ぜてきます。意図しない領域を意図しないと書くのは、生成AIに対する基本作法です。これらの宣言が破られていないかは、PR時点で grep やアーキテクチャテストが機械的にチェックする形にします。これらの対策の本質は、AIに渡すコンテキストの鮮度を、人間が意図的に管理することです。AIは時系列を理解しません。時系列を理解する責任は、コンテキストを編集する人間の側にある。「全部AIに見せて、AIが適切に判断する」と期待してはいけません。そして、剪定そのものも、できるところは自動化します。未使用コードの検出はCIで、依存関係グラフの古びはツールで、決定記録の有効期限は日付メタデータと cron で。手で気づいて手で消す運用は、忙しい週に必ず止まる。手の届く範囲を機械に任せ、人間は機械が判断できない境界だけを見る。要件の意味的な変化、業界用語の更新、ステークホルダーの交代——こういうのは人間にしか判別できません。逆に、未使用フラグの掃除や有効期限切れの検出は、人間がやるとミスが増える種類の作業です。自動化できるものは自動化させる。人間は機械が困る場所に集中する。剪定の規律は、この役割分担を持たないと続きません。要件を動くものへ散らす話と矛盾しない、というのが大事な点です。散らすけれど、散らした要件群が古びていく。古びた要件を、新しい要件と同じコンテキストへ置きっぱなしにすると、AIが両者を等価に扱います。散らす + 剪定する、両方をやって初めて、要件は健全に回ります。時間軸の扱いは、要件運用の中でも特に難しい課題だと感じています。コードと違って、文書には自動的なリファクタリング機構がありません。意識的に剪定する仕組みを作らないと、放置するほどコンテキストは腐っていく。要件を動くものとして散らした後の保守コストの大半は、ここにかかると思っています。ただし、これは現時点のAIの振る舞いに対する処方箋です。モデルが進化して、コンテキスト内の日付や状態タグを自然に重みづける能力を持つ可能性は、十分にあります。そうなれば、書いた剪定の負荷の一部は不要になるはずです。それでも、剪定する習慣そのものは残したほうがいいとも思っています。AIの解釈に丸投げしないで人間が剪定する規律は、要件の責任を保つための姿勢でもあるからです。状態ではなくイベントを残して、時間の真実を保つ時間軸の扱いに関連して、もう1つ書いておきたいのがドメインイベントを記録に残すという発想です。普通のシステムは、現在の状態だけを記録します。ユーザーテーブルには「現在のメールアドレス」が入っている。商品テーブルには「現在の在庫数」が入っている。過去の値は、上書きされて消えています。これは効率的ですが、過去の決定の経緯が消えるという弱点を持ちます。「このユーザーはいつから有料会員になったのか」「在庫が0になった時刻と理由は何か」を聞かれても、答えられない。状態を記録するだけでは、なぜそうなったかは見えません。これに対して、ドメインイベントを記録する設計があります。「ユーザーが登録した」「メールアドレスを変更した」「有料会員にアップグレードした」「在庫が補充された」「在庫が販売で減った」。それぞれをイベントとして時系列で残す。現在の状態は、イベントの蓄積から導出される派生物です。この設計の効用は、要件管理の文脈で大きい。要件が満たされていたか、いつから満たされていなかったか、を時系列で追えるからです。「在庫切れ商品が検索結果に出ないこと」という要件を、「商品Xに『在庫切れ』イベントが発生した後、検索イベントの結果に商品Xが含まれていない」という監査可能な命題に変換できます。イベントの記録は、AIに対する時間軸の補助にもなります。AIはコンテキストの中で時系列を判断できないと書きましたが、明示的に時系列で並べられたイベントログは、AIにとっても解釈可能です。「2025-04-01: 商品Xを登録、2025-04-02: 在庫を10個補充、2025-04-15: 在庫が0に、2025-04-20: 補充して在庫を5個に」と並んでいれば、AIは流れを追えます。状態だけを渡すより、イベント列を渡すほうがAIへ渡せる情報量が多い。時間軸を扱う設計には、こういう側面もあります。過去を消さずに残す設計が、未来の判断材料を保存する。これは要件発見の文脈でも、要件運用の文脈でも、効きます。構造化された印を残して、機械が読める履歴にする決定記録の「置き換え済み」のような印は、人間の読み手向けに作られてきました。AIにとっては単なる文字列に見える、と書きましたが、構造化された印として置けば、AIにも届きます。具体的には、決定記録の冒頭に機械可読なメタデータを置く。状態: 置き換え済み 置き換え先: ADR-042 valid_until: 2025-12-31 のような書き方です。これらをAIへのコンテキストに含めるとき、メタデータごと渡す。状態: 置き換え済み というキーが付いている記録は、現在の決定として使わない、というルールをコンテキストの先頭で明示する。こうすると、AIは「これは廃止された決定だ」を構造的に判別できる場面が増えます。完璧ではありませんが、自然言語の本文だけを渡すよりは確実性が上がる。効くのは、人間用と機械用を併記する設計です。「置き換え済み」の理由を本文に書くのが人間用。冒頭へメタデータで書くのがAI用。両者を分けて、それぞれの読み手に合わせた形で情報を置く。AIへ渡るコンテキストの設計は、ドキュメント運用の中で人間とAIの両方を視野に入れる仕事になります。要件のトレーサビリティで、何が何に依存しているかを追う要件を時間の中で生かし続けるには、何と何が繋がっているかが見えている必要があります。要件と要件、要件とコード、要件とテスト、要件とステークホルダー、要件と規制。これらの依存関係が見えていないと、変更が来たときに何が壊れるかが分からない。これがトレーサビリティの話です。完全なトレースは無意味トレーサビリティと聞くと、「すべての要件と、すべてのコードと、すべてのテストの間にリンクを引く」というイメージを持たれがちです。これは現実には機能しません。リンクが多すぎると、リンクを維持するコストが、リンクから得られる価値を上回ります。要件が1つ変わるたびに、何百本のリンクを更新する。それを誰がやるのか。リンクが古びていることに、誰が気づくのか。完全なトレーサビリティは、ほぼ確実に陳腐化する。私の周りでも、最初に張りすぎたリンクが半年後にほとんど機能していない、という光景を何度も見ました。リンクを引いた当初は完璧に見える。半年経つと、要件が変わってもリンクが追従されず、リンク自体が古びていきます。陳腐化は、努力不足ではなく、規模設計の失敗です。ここに必要なのは、戦略的な選別です。どの依存関係が本当に重要なのかを判断する。すべてのリンクを引くのではなく、変更が起きたときに確実に追跡したい関係だけを残す。これは設計判断であって、機械的な作業ではありません。「依存している」と「参照している」を分ける依存関係には、強さの違いがあります。私が現場で意識しているのは、強い依存と弱い参照の区別です。強い依存は、片方が変われば、必ずもう片方も変わる関係です。要件「在庫切れ商品は検索結果から除外する」と、テスト「在庫0の商品が結果に含まれないことを確認する」は、強い依存です。要件が変われば、テストも変えなければならない。要件が削除されたら、テストも削除する。弱い参照は、参考にしているだけで、必ずしも連動しない関係です。要件「商品検索の応答時間」と、似た性能要件を持つ別ドメインの要件「在庫検索の応答時間」は、弱い参照です。両方とも性能を扱っているが、片方が変わっても、もう片方が必ず変わるとは限らない。強い依存だけをトレーサビリティに残し、弱い参照は外す。こうすると、リンクの数が大きく減ります。疎なトレーサビリティのほうが、密なトレーサビリティより保守が続く。これは直感に反するかもしれませんが、現場で守れる規律のほうが、ドキュメント上完璧な規律より価値があります。そして、残した強い依存についても、リンク自体は人間が手で張らないようにしています。要件IDを命名規約に組み込み、コードのコメントやテスト名から自動で集めて依存グラフを生成する。手で張ったリンクは半年で陳腐化しますが、コードから自動生成したリンクはコードと同時に動きます。生成できる依存は生成する。手で張る依存は、生成できない種類のものに限る。これも、人手を減らす方向への一歩です。とりあえず、撤回条件を書いておきます。リンクの自動生成・自動更新が十分に効く環境では、密なトレーサビリティのほうが保守工数は低くなる可能性があります。たとえば要件管理ツールがコードと双方向に同期し、変更検知から差分提案まで自動化されている環境です。密リンクの陳腐化率を疎リンクと同等以下に保てるツールチェーンが手元にあるなら、私の主張は不要です。今のところ、そう言える環境を私は知らない。だから疎を勧めています。規制業界でリンクが監査要件として密に張られる場面は別の話で、そのときは維持コストを払い切る覚悟がまず先に来ます。3つの問いで判定する「強い依存」と「弱い参照」を、もう一段だけ精緻に見ます。私は要件間の関係を、3つの問いで判定しています。1つ目、片方を直すと、もう片方も書き直しになるか。要件「在庫切れ商品を結果から除外する」と、テスト「在庫0の商品が結果に含まれないことを検証する」の関係なら、答えはイエスです。要件の文言が変われば、テストも書き換わる。逆に、参考程度に並んでいる別ドメインの要件との関係は、ノーです。2つ目、直す場所と直される場所は、どれくらい遠いか。同じファイル内なら近い。同じリポジトリの別モジュールなら少し遠い。別リポジトリ・別チーム・別契約なら、調整コストが跳ね上がる。距離は物理ファイルの距離だけでなく、組織の壁の距離も含めて見ます。3つ目、この要件は、よく動くのか、ほとんど動かないのか。事業の差別化に関わる要件はよく動く（常に磨く必要があるから）。業界標準に従うだけの要件はほとんど動かない（誰も触る理由がないから）。3つの答えを組み合わせると、何にトレースを引くべきかの判断が変わります。「イエス・近い・よく動く」なら必ずトレースを引く。要件と直接実装テストの関係です。「ノー・遠い・動かない」なら引かない。汎用領域の業界標準に対する参照などがここに該当します。一番厄介なのは、「イエス・遠い・よく動く」——よく動くのに置き場所が遠い要件群です。これは変更のたびに全社を巻き込んで疲弊する構造で、放置するとコードベース全体の保守性を蝕みます。要件を見たとき「これはよく変わるはずなのに、関連する箇所がリポジトリ全体に散らばっている」と気づいたら、まとめ直す判断が必要になります。まとめ直すのは設計の仕事ですが、要件発見の段階でこの構造を見抜けるなら、設計の手戻りを大きく減らせます。3つの問いは、その見抜き方の補助線です。トレーサビリティは、リンクを引く作業ではなく、要件群の関係性を診断する作業だと考えています。3軸で診断して、強い依存にだけリソースを集中する。これが、AI時代に保守可能な要件運用の輪郭です。何にトレースを引くか私が必ず引くようにしているのは、4種類です。要件→テスト、要件→コード、要件→決定、要件→制約。要件とテストの関係が一番直接的で、テストが通らなければ要件は満たされていない。要件とコードは、1つの要件が複数のファイルにまたがることもあるので、PR の単位で紐づけるのが運用しやすい。要件と決定は、その要件がどの決定の結果として書かれているかを見えるようにするためのもので、決定の記録に「この決定の帰結として X と Y の要件を導入する」と書くと、決定が見直されるときに要件も見直せる。要件と制約は、ビジネス制約や規制との対応で、ここを引いておかないと法令対応のときに詰まります。これら以外のリンクは、引かないことも多いです。引いても保守できない。割り切って捨てる判断が、トレーサビリティを生かす条件です。業界によってはトレーサビリティが必須になるここまでは、私が普通の事業会社で運用している話です。業界によっては、もっと厳密なトレーサビリティが法令や安全基準で求められる領域があります。航空・医療機器・自動車・金融・原子力・軍事。これらの領域では、どの規制条項がどの要件に対応しどのコードに実装され、どのテストに検証されているか。ドキュメントとして証明できる必要があります。この領域では、トレーサビリティは「やったほうが良い」ではなく「やらないと出荷できない」になります。私が普段書いている疎なトレーサビリティの話とは別の世界です。AI時代になっても、規制業界での要件管理の厳密さは変わりません。むしろ、AIが書いたコードを規制対応として認めるかという論点が、新しく出てきています。私は普段、規制業界の側にいません。だから、疎なトレーサビリティを勧めて書いている自分に、半分の留保を持っています。以前、医療系SaaSの監査対応に関わる相談を半日だけ受けたことがあって、要件1行に対してSOPと試験記録とリスク評価が三点セットで紐づいている運用を見せてもらった。維持コストは私の現場の感覚で言えば、桁が1つ違いました。あれを「重い」と言える立場にないのも、私です。疎で十分と言えるのは、出荷を止められるのが自分たちのKPIだけだからです。出荷を止めるのが法と人命なら、密で当然です。私の書いている疎の話は、出荷の重さが軽い側の処方箋だと、断っておきます。業界の前提が違えば、トレーサビリティの形も違う。「うちでは疎で十分」と判断する前に、自分のいる業界が何を要求しているかを確認するのが、最初の仕事です。AIに渡すコンテキストとトレーサビリティトレーサビリティは、AIへのコンテキスト提供にも効きます。ある機能を変更したいとき、AIへ「この機能を変更してください」と渡すだけでは、関連する要件・テスト・決定をAIに見落とされる可能性があります。トレーサビリティが整っていれば、「この機能に紐づいている要件はこれら、テストはこれら、決定はこれら」とコンテキストとして添えられます。AIによる見落としの確率が下がる。逆に、トレーサビリティがなければ、AIは目の前のコードしか見ません。要件を踏まえずにコードを書き換え、テストを通さない実装を返してくる。トレーサビリティは、AIに「文脈の幅」を渡すための道具でもあります。ただし、コンテキスト全部を渡せばいい、という話ではありません。時間軸の節で書いた通り、コンテキストが膨らむほどAIの精度は下がる。今の変更に直接効くトレーサビリティだけを抽出して渡す規律が、AI時代の使い方です。トレーサビリティを引くこと、引いたものから今に必要な範囲を抽出すること、両方が要件運用の仕事になります。要件を回転させる、更新可能な資産にするここまで並べてきた、要件を動くものに散らす、決定を蓄積する、時間軸を意識的に管理する、トレーサビリティで依存を見える形にする——これらは結局、要件を回転する資産として扱うための仕掛けの集まりです。回転には2つのリズムが要ると、私は見ています。探索のリズムと適応のリズムです。探索は「正しいものは何か」を再び問い直す動き。適応は「変わった現実に合わせて要件を書き換える」動き。前者を怠ると、要件は古い問いの答えのまま固まる。後者を怠ると、要件は現実から浮く。両方のリズムが回ることで、要件は時間に殺されずに資産として残ります。AI時代に困るのは、適応のリズムだけが速くなり、探索のリズムが置き去りにされる現象です。速く適応できることは、正しい方向に適応していることを意味しない。ドキュメントは静的です。書いた瞬間がピークで、あとは下がる一方。読み返されることが少なく、更新されることはもっと少ない。3ヶ月後には実装と乖離している。これがドキュメントの宿命です。動くものに散らされた要件は、毎回読まれます。エージェントがコードを書くたびに CLAUDE.md / AGENTS.md が読まれ、skillが読まれ、テストが実行され、lintが回る。読まれる頻度が高いものは、間違いに気づかれる頻度も高い。間違いに気づかれれば、修正される。修正されたものが、また次の起動で読まれる。これが回転です。要件が、毎日の実行のたびに、読まれて検証されて修正されていく。要件が現役で動き続ける。1つ、誤解しやすい区別があります。回転の仕組みはテストの拡張ではない、という点です。テストが落ちたらバグですが、要件側の検証が引っかかったとき、それは多くの場合会話の引き金でしかありません。設計の前提が変わった、要件が動いた、現場の構造が想定と違った——どれも違反として検出されますが、自動で「直せ」とはならない。人間が立ち止まって判断するための割り込みとして鳴っている。要件を動くものに散らした後の運用も、これと同じです。lintやテストやアーキテクチャテストが鳴ったとき、すぐ機械的に直すべきものと、立ち止まって要件そのものを見直すべきものが混ざっている。鳴った警告を全部「ノイズ」と扱う組織と、「会話の入口」と扱う組織で、要件の回転速度は段違いになります。回転には摩擦が必要回転を意味のあるものにするには、いくつかの仕組みが要ります。摩擦のない回転は空回りで終わる。AIへ任せきりにすれば、AIは自分の都合のいいように要件を書き換え、それが本当に正しいかを検証する力が弱くなる。回転している、と感じるとき、回っているのは仕組みではなく人間の関心だったりします。誰かが毎週ルールを読み返す。誰かが違反に気づく。誰かが書き直す。仕組みが回しているように見えて、関心が切れた瞬間に止まる。だから回転を測るときは、ルールの更新頻度だけでなく、読まれた回数・議論された回数・書き換えを提案された回数を見ます。仕組みの回転と人間の関心の回転は、別の指標です。最初に置くのが、エラー履歴のメモリ化です。AIがコードを書いてテストが落ちた、レビューで指摘された、本番で問題が出た。これらをエラー履歴として残し、次の起動で参照できる場所に置く。次にAIが似たコードを書こうとしたとき、履歴を見て同じ過ちを避ける。要件が学習する形です。そこから一段先で、ルールファイル自身の書き換えをAIに提案させる運用に進みます。要件を書いたファイルに対して、AIが「このルールは古いのではないか」「ここに追記すべき知見が見つかった」と書き換え案を出してくる。ただし、自己修正には必ず人間の承認を挟みます。これは譲ってはいけない一線で、AIが自由に書き換えられるようにすると、ルールがAIの都合のいい方向にドリフトしていく。気づいたときには、要件が骨抜きになっています。仕組みの内側を回すのに加えて、人間がハンコを押す場所を最初に決めておく規律も、同じ場所に置いています。仕様の合意、実装のレビュー、本番デプロイ、本番後の振り返り。どの局面で人間が一度立ち止まり、自分の名前で承認するのか。これを機能を作り始める前に決めておく。全部に立ち止まると人間がボトルネックになり、どこにも立ち止まらないと誰の判断でもない実装が本番へ流れます。立ち止まる場所を意識的に選ぶこと自体が、自分の判断責任を残すための設計です。私の現場での運用は、こうなっています。要件の合意は人間が必ずやる。実装はAIがやる。レビューはAIが先に走らせ、人間は要件への適合だけを見る。デプロイは小さい単位なら自動、大きい単位は人間承認。本番後の振り返りは、AIがログを集めて初稿を書き、人間がコメントする。そして最後に、回転の定量評価を組み合わせます。要件が回転している、と言うとき、どれくらい回っているのかを測る。ルールファイルの更新頻度、レビューでの指摘の減少、AIが書いたコードの差し戻し率、本番障害の発生率。これらを追えるようにすると、回転が測定可能な改善活動になります。測定できないと、回転している気になっているだけ、という落とし穴があります。エラー履歴を残しても誰も見ていない、ルールファイルを更新しても活用されていない、これが普通に起きる。私のチームでも、回転を測る指標を3回作って3回定着しなかった。ダッシュボードを作った週は皆が見る。翌週には誰も見ない。測定の話を語る私自身が、測定を続けられていない。測れば回るのではなく、測り続けられる仕組みだけが回す。指標を作る前に、誰がいつそれを見るかを決めておかなければ、測ったことすら忘れます。私が3回失敗してから到達した結論は、人間に「見に行く」を期待しないことです。閾値を超えたら通知が飛ぶ、定期的にレポートが自動生成される、PRに自動コメントが付く。見に行かなくても向こうから来る仕組みにしないと、測定は続きません。可視化は大事ですが、可視化したものを誰かが能動的に見に行く運用は、私の経験ではほぼ全部止まります。回転の測定も、できる限り機械が回す側に倒します。ハーネスは万能ではないここでいうハーネスとは、テスト、型、lint、CI、アーキテクチャテスト、そして CLAUDE.md / AGENTS.md や skill といった、動くものへ散らした要件を機械的に受け止める仕掛け全般のことです。要件をどこかに書くなら、ドキュメントの片隅ではなく、AIが毎回読みに来るこれらのファイルに書く——というのが、私の現場での原則になっています。要件を動くものに散らした後の保守が、要件の価値を上回るリスクは、常に意識しています。ドキュメントと動くものを比べて「動くもののほうが回転する」と書きましたが、回転する代わりに毎日少しずつ保守を要求するのが代償です。プロジェクトの段階によっては、ドキュメントで書いて時々見直すほうが、トータルで安いケースもあります。プロトタイプ段階、ごく小規模のチーム、短命なコード。これは、動くものに散らすコストを軽くしておくほうが合理的です。動くものへの分散は万能ではない。導入する範囲は、組織の規模と寿命の見積もりで決めるしかありません。コアの要件は動くものに焼き込む。汎用領域はドキュメントで十分。境界は組織ごとに違います。もう1つ、自分が現場でハマったのが、緩和策そのものの罠です。生成と検証の両方をAIに任せていた週があって、PRの量はむしろ増えていました。書かせて、レビューもさせて、テストも書かせる。ダッシュボードは緑のまま、月曜より金曜のほうが進捗があるように見える。それなのに、何が動いているかを自分が説明できる範囲は、月曜より金曜のほうが狭くなっていました。負債を積む側に手を抜き、負債を減らす側にも手を抜くと、両端で同じ穴が開きます。私はこの感覚を、自動化全般の話にも一般化したいと思っています。AIに任せるほど、人間に残るのは「自動化できなかった残余」、つまり最も負荷が高く曖昧で、判断が要る部分だけです。普段はAIに任せて自分は監視役に回るので、平時にスキルは萎縮する。いざ介入が必要になった瞬間、本来いちばん準備があるべき人間が、いちばん準備が薄くなっている。これは生成AIに限った話ではないと思いますが、AI時代に同じ構造が、コーディングの中に深く入ってきました。監視役は安全な席に見えて、いちばん介入の瞬間にひるむ席です。私はこれを、自分が深夜の障害対応で「あれ、自分はこのコードのどこから読めばいいんだっけ」と固まった瞬間に、身体で覚えました。そしてもう1つ言えるのは、人間の関心が抜けた仕組みは、空転している間に静かに腐ります。緑のダッシュボードと、増えるPR数と、テストを通り続けるCI。これらは「全部うまく回っている」を演出してくれますが、関心の代わりにはなりません。仕組みが回っていることと、人間が中身に注意を向け続けていることは、別の指標です。私のチームでは、この区別を1つの問いに落として運用しています——「今週、自分はこのコードベースの新しい場所を1つでも読んだか」。読んでいなければ、関心は抜けています。回転は、ドキュメントだけでは起きません。動くものに散らされた要件だけが、回転する。要件をドキュメントだけに置かない決断が、回転を始める最初の一歩です。ただし、その決断はプロジェクト全体で下す必要はなく、回転させる価値がある領域に絞っていい。人間に残るもの、「書く」から「責任を負う」へここまで、要件を動くものに散らし、決定を蓄積し、時間とトレーサビリティを管理しながら回転させる話をしてきました。読みようによっては、人間の仕事が減っていく話に聞こえるかもしれません。実際、コードを書く仕事は減ります。仕様書を書く仕事も減ります。テストを手で書く仕事も減ります。レビューの一部もAIが代替する。要件発見の道具立てそのものも、AIが補助できるようになってきました。AIで支える要件発見と、AIのための要件発見要件発見の場面で、AIができるようになってきたことが増えています。曖昧性を含む要件文の検出、過去の議事録から繰り返し出てくるテーマの抽出、ユーザーフィードバックの感情分析、関連する要件のクラスタリング、キーワードの自動マッピング。これは要件アナリストの作業を加速します。要件発見の生産性は、確実に上がりました。それと並行して、もう1つ別の方向の要件発見が必要になっています。AIを内包するシステム自体に対する要件です。AIや機械学習が組み込まれたシステムには、これまで主流だった要件カテゴリには収まらない品質要求が出てきます。たとえば、説明可能性。「AIがなぜそう判断したか、人間に説明できる必要がある」。非差別性。「AIの出力が、特定の属性で偏った扱いをしないこと」。そして、安全性と規制への適合。「AIが安全基準を満たし、関連する規制に準拠していること」。これは、AIを使わないシステムでは出てこなかった、新しい品質要件のカテゴリです。そして、これらの要件は従来の検証可能性の議論をそのまま当てはめにくい。「説明可能であること」をテストとして書こうとすると、「説明とは何か」「誰にとって説明可能か」を定義しなければなりません。「非差別であること」を測るには、「差別とは何か」「どの属性に対する差別か」を決めなければなりません。これらの定義そのものが、新しい要件発見の対象です。要件発見は、AIに支えられて加速する仕事であり、同時にAIを対象として新しく拡がる仕事でもある。両方が並走します。それでも、人間に残るものがある要件発見が加速し、コードを書く時間が減り、レビューの一部が自動化されても、人間に残るものがあります。責任です。AIは法的責任を負えません。経済的責任も負えない。説明責任、継続的責任、社会的責任、これは全部AIが負えないものです。本番で事故が起きたとき、ユーザーに謝罪し、再発防止策を約束し、組織の信頼を立て直すのは、人間です。法的責任を負えない理由は単純で、契約の主体になれないからです。インシデントが起きたとき、賠償の支払い、再発防止の宣誓、規制当局への説明。いずれも署名できる主体が要ります。AIは署名できない。だから責任は、AIを業務に組み込んだ人間と組織に自動的に巻き戻る。「AIが書いた」は責任の所在を変えず、出した側の不作為として記録されるだけです。これは抽象的な道徳論ではなく、エンジニアリングの実務に直結します。AIが書いたコードで本番障害が起きたとき、「AIが書いたので分かりません」では通らない。書いたのが誰であろうと、出したのは私たちだ。そして、責任を負うためには、何が動いているかを理解できる状態を維持する必要があります。AIが書いたコードを、説明不能なまま本番に出していると、責任の取りようがない。「動いているけど中身は知らない」というコードは、技術的負債という言葉では足りない。確率的負債だ、と私は呼んでいます。動いている根拠が、テストで担保されているのか、AIが学習した分布の偶然に依存しているのか、誰も説明できなくなる状態のことです。反論があり得ます。「人間が書いたコードも、書き手が抜ければ説明できなくなる。AIコードだけを特別扱いする理由は何か」。これは半分当たっています。違うのは、量と速さです。人間のコードはレビューと運用の中で、誰かしらが部分的に文脈を継いでいきます。AIのコードはそれが3時間ごとに、誰の手も通らずに積み上がる。同じ「説明できない」でも、積み上がる速度が桁で違う。確率的負債は新種の負債ではなく、速度で性質が変わった既存の負債です。自分が「負債」と呼んできたものを、もう少し腑分けしてみます。手元では3つに分かれて見えます。コードに宿る負債は、保守性が落ちる症状として現れる。これがいわゆる技術的負債です。次に、人とチームの頭の中に宿る負債は、共有理解が劣化し、同じ質問をしても答えが揃わなくなる状態として現れる。最後に、外に書き出した知識に宿る負債は、ドキュメントもADRもテストも残っているのに、何のために存在するのか誰も思い出せない、という形で現れる。3つに分けて初めて見えるのは、私が前節までしつこく書いてきた「決定の記録を残せ」が、ほとんど頭の中の問題ではなく、外に書き出した知識の問題だったということです。limit > 100000 のような閾値が、法規制由来なのか、過去障害の再発防止なのか、大口顧客向けの例外なのか、もう不要なのか——これがわからないとき、症状の正体は別の場所にあります。チームの共有理解の劣化ではなく、コードと事業判断を繋いでいた鎖が、どこかで切れている。対策は「チームでもう一度握り直す」ではなく、ADRとドメイン制約をコードに紐づけて残すことに倒れる。test_case_17 という名前のテストが期待値だけ固定されているなら、テストはあっても鎖はない。ADRがあってもコードから辿れなければ、それも鎖が切れている。鎖が切れた書類は、書かれていない書類より始末が悪い。書かれていれば「ある」と思い込まれるからです。AIで問題が深刻に見える理由も、この3層で整理すると、自分の中ですっきり収まりました。コードに宿る負債は、AIが書く量と速度がそのまま乗るので、単純に増える。頭の中に宿る負債は、AIが代わりに書く分だけ人間が読まずに済むので、本来書きながら形成されていたはずのメンタルモデルが作られず、積もる。外に書き出した知識の負債は、アイデアがチケット → プロンプト → コードと圧縮される過程で、なぜそうしたかが落ちて積もる。AIは3層全部に対して、同じ方向の増幅装置として効いている——というのが、ここ1年くらい現場で見ていて私が辿り着いた見立てです。問題そのものはAI以前から現場にあった。新しくなったのではなく、もとからあった構造が、桁で速く回り始めただけです。確率的負債が積み上がった先には、組織として説明責任を果たせない世界があります。これは技術の問題ではなく、組織の存続の問題です。だから、AI時代のエンジニアの仕事は、コードを書くことから、コードに対して責任を負える状態を保つことに変わっていきます。責任を負うとは、説明を求められたときに、降りずに立っていられるということです。AIが書いた、だから知りません、は通らない。立っていられる構造を、コードを書く前に作っておく。要件を言葉にするとは、結局、その構造を作る行為です。責任を負える状態とは、ざっくり次の条件が同時に揃っている、ということです。何のためのコードかが言語化されている（要件）どう動くかが説明できる（設計）動いていることが確認できる（テスト）何が起きたかが追跡できる（ログ・イベント）必要なら作り直せる（仕様の独立性）何が何に依存しているかが追える（トレーサビリティ）整っていれば、AIが書いたコードでも責任を負えます。整っていなければ、人間が書いたコードでも責任を負えない。責任の前提は、コードの作者ではなく、コードを取り巻く構造です。私自身、6つのうち何度か外しているのは「何が起きたかが追跡できる」のところで、深夜に呼ばれてログを開いた瞬間に「足りない」と気づくたび、責任が立たない瞬間の冷たさを思い出します。そう考えると、要件を言葉にするという行為は、ただの「ドキュメントを書く」作業ではなくなります。責任を引き受ける宣言だ。私自身の失敗からAIが返してきたコードを、ざっと眺めて「動きそうだ」で本番へ流していた時期がありました。読み込まなかったわけではない。読んだ気になっていた、と言ったほうが近い。差分の量に対して、目を通した行数の割合が、たぶん3割を切っていた週があります。それでも金曜の夜にマージボタンを押せたのは、テストが緑だったからです。テストが拾える範囲だけが安全で、その外側は丸ごと運だった、という事実に、半年気づきませんでした。私の手元では、半年で3回ほど深夜の障害対応に呼ばれた覚えがあります。そのうち少なくとも2回は要件の穴で、AIが「常識」で勝手に埋めた解釈と、現場の運用が食い違っていました。そのときに繰り返し自問したのは、「これ、自分で書いていたら、この穴に気づけたか」でした。たぶん、半分は気づけた。半分は気づけなかった。本当の問題は、AI抜きでも見落としていたような穴を、AIに任せたことでより速く、より広く撒いてしまったことです。あのとき気づいたのは、読まれない要件は要件ではなく、読まれないコードはコードではないということです。AIが書いた、AIが読む、それは構わない。しかし人間が責任を負う部分は、人間が読める形で残しておく必要がある。責任を負える形で残す、というのは、結局、これまで触れてきた話に戻ってきます。要件を言葉にする。動くものに散らす。決定を蓄積する。時間を管理する。トレーサビリティを引く。これは速さのためでなく、責任を成立させるための前提条件です。AI時代のエンジニアの新しい仕事は、コードの執筆ではなく、コード執筆の仕組みづくりのほうへ重心が移ってきている、というのが私の見立てです。仕組みの中には、要件を引き出す技術、要件を実行可能な形にする技術、要件を時間の中で保つ技術、依存を見える形にする技術、回転を回す技術、そして責任を成立させる技術が含まれます。書くスキルが不要になったのではありません。書くスキルの置き場所が変わった。コードを書くから、コードを書かせるための前提を書くへ。書く対象が、コードから、コードの源泉に変わっただけです。置き換えではなく、拡張AI時代の話で繰り返し聞こえてくるのが、「AIで人を減らせる」という発想です。私はこの方向に、強い違和感を持っています。AIで成果の伸びる組織は、人を減らすところではなく、1人ひとりが扱える仕事の幅を広げるところだと考えています。これまで個人の手では届かなかった範囲の業務を、AIの補助で扱えるようにする。1人で複数のドメインを跨いで働けるようになる。チーム全体としてカバーできる領域が広がる。観察、対話、判断、責任の引き受けは、AIが補助しても人間に残る。これが、AIを使う本来の効用だと、私の知っている範囲では見えています。「AIに置き換えられる仕事」と「AIで拡張される仕事」は、しばしば対立して語られます。ただ、現場では同じ仕事の中で両方が起きることが多い。仕事の一部はAIに任せられるようになり、その分浮いた時間で別の難しい仕事へ取り組めるようになる。仕事を減らすのではなく、仕事の中身を変える、というのが、AIで成功している組織の共通点に見えます。個人のツールから、チームの共有資産へもう1つ、AI時代の運用で見落とされがちなのが、AIを個人のツールに閉じ込めないことです。AIを使い始めたとき、多くの組織でまず起きるのは、各自が好き勝手にAIを使う段階です。誰がどんなプロンプトでどんな成果を出しているかが、組織として把握されない。同じ問題を別々の人が別々のプロンプトで解いていて、誰も再利用できない。AIの能力が個人の暗黙知に閉じ込められる、という状態です。これは要件運用にとって、無視できない損失です。AIに渡す要件文、決定の記録、エラー履歴、レビューの観点、これらが個人のローカルに散らばっていると、組織として要件を保つことができません。チーム全体でAIに同じ前提を渡せる仕組み、共有のskill、共有のルールセット、共有の決定記録。これらが整って初めて、組織レベルの要件運用が成立します。要件を時間の中で生かし続けるには、AIの使い方も個人芸からチームの共有資産へ移行する必要があります。誰か1人がうまくAIを使えても、その知見が組織に残らないなら、その人がチームを離れた瞬間に消えます。AIへの指示の仕方、コンテキストの渡し方、レビューの観点。これらを共有資産として残すこと自体が、要件運用の一部だと考えています。実装より意図、編集者としてのエンジニアもう一段、踏み込んで書いておきます。AI時代に、エンジニアの仕事の重心が、実装から意図へシフトしている、と感じる場面が増えてきました。実装は、AIが速くなった領域です。要件さえ十分に渡せば、コードはAIが書く。書いたコードを動かして、テストを通して、PRを作る、までが3時間で済む場面も普通にあります。実装そのものに人間の手が必要な比率は、確実に下がっている。その代わりに、人間の時間が要るのは、何を作るかを決めるところ、作ったものが正しいかを判定するところ、長期で何が起きるかを予測するところです。前者は要件発見、後者は責任を負える状態の維持、と言い換えられます。エンジニアの仕事を比喩で表すなら、コードを書く人から、編集者・アーキテクトに近づいてきている、と感じます。書いたコードが整合しているか、目的に沿っているか、組織の方針と合っているかを判定し、必要なら書き直しを指示する。この役割は、コードを書くスキルの延長にありますが、強調点が違います。書く力より、読み判定する力、指示する力、説明できる力が重みを増しています。そして、編集者の仕事には、もう1つ重要な側面があります。何を残し、何を捨てるかを決めることです。AIは要求すれば際限なくコードを生成します。3時間ごとに新しい実装案が出てくる。これを全部採用したら、コードベースは崩壊します。だから、捨てる判断が、編集者としてのエンジニアの中心的な仕事になります。捨てるためには、判断軸が要ります。要件、トレードオフ、過去の決定、将来の見通し、組織のコア。これらを参照して、「これは採用、これは却下、これは保留」を決める。この判断は、AIには代替できません。AIは「最適解」を提示しますが、「自分たちにとって採用すべきか」の判断は、組織の文脈に依存するからです。実装より意図、書くより読み判定する、生成より捨てる。これは、AI時代のエンジニアの仕事の重心の移動を、別の角度から表す言葉です。要件を言葉にする規律は、この新しい仕事の中心にあります。意図を明文化し、判定基準を共有し、捨てる根拠を残す。すべてが、要件発見の延長線上にあります。実務での最低ライン抽象的な話ばかりしてきたので、最後に、私が現場で守っているルールを並べておきます。これは絶対の正解ではなく、私が今のところ「これくらいはやっておくと事故が減る」と思っているラインです。ラインを引く前に、1つだけ前提を書いておきます。建物にたとえるなら、建築家はすべての壁と継ぎ目を検証しようとはしません。耐力壁——破られたら全体が傾く構造材だけを検証します。要件運用も同じで、すべての要件を動くものに焼き込もうとすると、保守コストが価値を超えます。最低ラインの本質は、どの壁が耐力壁かを見抜く判断だ、というのが私の考えです。以下に並べるのも、自分の現場で「ここは耐力壁だ」と判断したものだけです。拾って使うかどうかは、各自の現場で決めてください。まず、コードを書く前に、Why（なぜ作るか）・誰のため・何を満たせば完了かを、1段落で書きます。これを CLAUDE.md / AGENTS.md か該当の skill、あるいは PR 本文に貼り付ける。書けないなら、まだ作ってはいけない。書けない要件は、合意できていない要件です。書く受け入れ条件は、過去形で書きます。「ユーザーが検索できる」ではなく「ユーザーが在庫商品の一覧を取得できた」。過去形で書ける条件は、検証可能です。「できた／できなかった」が判定できる。未来形は願望、現在形は説明、過去形だけが要件だと考えています。曖昧さは、文章ではなく型に押し込みます。「在庫がある」を文章で書くのでなく、InStockProduct 型を作る。「ユーザーがログイン済み」を文書で書くのでなく、AuthenticatedUser 型を要求する。「メールアドレスが妥当」をバリデーションでやるのでなく、Email 型のコンストラクタで弾く。型の中に閉じ込めた要件は、誰にも破れません。新しい機能や新しいシステムを設計するときは、最初に Bounded Context を引きます。どこからどこまでが、このコンテキストか。何を持ち込み、何を持ち込まないか。曖昧な境界は AI が一番苦手な場所であり、人間が一番ミスする場所でもあります。境界を引くだけで、要件のかなりの部分が自動的に決まる。事故の履歴は、読み返せる場所に残します。AIが書いたコードでテストが落ちた、レビューで指摘された、本番で問題が出た。これらを .claude/logs/ でも docs/incidents/ でも構わない、必ずどこかに残す。同じ間違いが2回起きたら、それは要件の穴です。3回目を防ぐために、ルールに昇格させる。そして、人間がハンコを押す場所を、機能を作り始める前に決めます。仕様の合意、実装のレビュー、デプロイ、本番後の振り返り。どの局面で人間が立ち止まり、自分の名前で承認するのか。後から「ここでも見てもらおう」と足すのは、たいてい遅い。立ち止まる場所を意識的に選ぶことが、自分の認知資源を守る。これらは、絶対に守るべきルールではなく、守ったほうが事故は減るラインです。プロジェクトの規模や成熟度によって、守るべき位置は変わる。新規プロジェクトのプロトタイプではほとんど不要、本番運用しているシステムでは全部必要、ということもある。自分のプロジェクトの段階を見ながら、必要なラインを引くのが現実解だと考えています。そして1つ、私が大事にしているのは、ラインを引くことそのものを意識化することです。「うちはこのラインを守る」と決めて、チームで合意し、CLAUDE.md / AGENTS.md に書いておく。書いてないラインは、いずれ崩れます。書いてあれば、AIと人間がそれを基準に動ける。ラインを引くのも、要件を言葉にする行為の一部です。守れなかったラインの話ラインを並べたついでに、守れなかった夜の話を1つ書いておきます。数年前、ある機能で「機能ごとに1段落の要件文を書く」を自分のチームの最低ラインに据えました。PRテンプレに欄を作り、空欄では通さない運用にした。最初の3週間は機能した。4週目から、要件: タイトルと同じ 要件: チケット参照 という空欄回避が混じり始めた。私自身、金曜日の夜にPRを通したくて、要件: 仕様書通り と書いて自分のPRをマージした記憶があります。仕様書のどこを指しているかは、書きませんでした。そのPRが半年後に深夜障害の原因になった。書いた瞬間に「これは要件文ではない」と分かっていた。分かっていて、出した。ラインは引いた瞬間に守られるのではない。守られるのは、ラインを破りたくなった瞬間に、破らない選択をした人がいたときだけです。テンプレも、CIチェックも、自動化も、破りたい人間の意志には勝てない。あの夜の私は、要件: 仕様書通り を書ける人間でした。だから、最低ラインは技術ではなく、ラインを引いた本人がそれを破りたくなる瞬間に何を選ぶか、で決まります。並べたラインの中で、私が一番何度も破ったのは「過去形で書く」と「曖昧さは型に押し込む」の2つです。過去形は面倒で、つい「ユーザーが検索できる」と書いて出した。型に押し込むのも面倒で、String のまま通した。どちらも、その場では数分の節約になり、半年後に数時間の障害として返ってきました。節約した時間と返ってきた時間の差は、だいたい二桁です。計算が合わない投資は、たいてい本人だけが気づかない——というのが、深夜に呼び出されてようやく覚えたことの1つです。だから、最低ラインの本当の使い方は、ライン自体を増やすことではなく、自分がどのラインを破りやすいかを記録することだと考えるようになりました。私の場合は「過去形」と「型」です。あなたの場合は別かもしれない。破りやすいラインを自覚しているチームと、ラインだけ並べているチームでは、半年後の障害件数が違ってきます。たぶん。おわりに書き終えた要件が古び始める、という事実を、悲しい話として受け取らないようにしてきました。悲しがる暇があるなら、もう一行剪定したほうがいい——というのが、私が自分に言い聞かせている台詞です。古びるからこそ、書き直す機会があります。古びたまま放置されるかどうかが、たぶん唯一の問題です。剪定の規律を持てるかどうかで、書いた要件の生死は決まる。古びるのは要件の罪ではない。古びたまま置いておくのは、置いた人間の選択です。書いた人間が剪定しないなら、書かなかったのと同じ。書いた瞬間に責任が発生し、剪定をやめた瞬間に要件は死ぬ。要件は時間の中で生きます。書いた瞬間が終わりではなく、書いた瞬間が始まり。そう考えるようになってから、要件を扱う重さが少し変わった気がしています。動くものに散らし、決定として記録する。時間軸を意識的に管理し、依存をトレースし、回転を回す。これらの仕掛けはすべて、要件を死なせないためにある。ドキュメントだけで要件を保とうとすると、3ヶ月で腐ります。ドキュメントのままの要件は、誰にも読まれず、検証されず、更新されない。AIに渡しても、コンテキストとして埋もれて死蔵される。書いた瞬間に死んでいる要件は、要件の形をしているだけで、要件として機能しません。そして、AIは時間軸を理解しません。時間を立体に戻すのは、たぶんコンテキストを編集する側の仕事です。依存関係を見える形にしておかないと、変更が来たときに何が壊れるかが分からない。すべてのリンクを引く必要はないのかもしれません。強い依存だけを残し、弱い参照は外す。疎なトレーサビリティのほうが、密なトレーサビリティより保守が続く——少なくとも、私が知っているツールチェーンの範囲ではそう見えます。そして、人間に残るのは責任です。AIが書いたコードに対しても、私たちは責任を負わなければならない。責任を負うためには、何が動いているかを説明できる必要がある。説明できる構造を保つこと——それがAI時代のエンジニアの新しい仕事になりつつある、というのが現時点の見立てです。この見立てが、しばらく経った後にも同じ言葉で通用する自信は、正直ありません。AIエージェントの能力が伸びれば、「察しがない」「時間軸が見えない」と書いた性質の一部は、姿を変えるはずです。それでも、要件を時間の中で生かし続ける責任は、誰がコードを書く時代でも残るのではないかと思っています。媒体や道具は変わっても、自分が何を作ろうとしているかを、自分で言葉にできること、そしてそれを生かし続ける責任を負うこと。これだけは、AI時代になっても、その先の時代になっても、ずっと人間に残ります。3部のシリーズを通して、要件を言葉にし、動くものに変え、生かし続ける流れで書いてきました。これは、AIに飲み込まれず自分たちの形を保つための私なりの地図です。完成図ではなく、まだ描き直しが続く地図。最後にもう1つだけ書いておきたいのは、要件はプロジェクトを跨いで生き続けるということです。プロジェクトには始まりと終わりがあります。プロダクトには始まりがあって、終わりはまだ見えない。組織はその両方を抱えながら動いていく。プロジェクト単位で書かれた要件は、プロジェクトと一緒に死ぬ。プロダクトに紐づけて書かれた要件は、もう少し長く生きる。組織の意思に紐づけて書かれた要件は、その組織が続く限り残る。要件をどの時間軸に紐づけて書くか——この選択が、要件の寿命を決めます。3部に分けて書いてきた話は、結局、要件を組織の時間軸へ少しでも近づける作業でした。おい、要件を殺すな。書いて終わりにするな。動くものに散らした後も、毎日読み返し、毎日剪定し、毎日少しずつ良くしていけ。要件は生き物だ。生き物として扱われた要件だけが、AIの時代に組織を生かし続ける。たぶん、その先の時代にも。参考書籍ソフトウェア要求　第3版作者:カール ウィーガーズ；ジョイ ビーティ日経BPAmazonソフトウェア見積り　人月の暗黙知を解き明かす作者:スティーブ マコネル日経BPAmazonはじめよう！ 要件定義 ～ビギナーからベテランまで作者:羽生章洋技術評論社Amazonだまし絵を描かないための－－要件定義のセオリー作者:赤俊哉リックテレコムAmazonこんにちは！要件定義①【情報活用とデータベース編】 ビジネス ✕ IT企画作者:羽生 章洋技術評論社Amazonバイブコーディングを超えて ―AI時代を生き抜く開発者の未来作者:Addy Osmani,佐藤 直生（翻訳）オーム社Amazonドメイン駆動設計をはじめよう ―ソフトウェアの実装と事業戦略を結びつける実践技法作者:Vlad Khononovオーム社Amazonソフトウェア設計の結合バランス 持続可能な成長を支えるモジュール化の原則 (impress top gear)作者:Vlad KhononovインプレスAmazonソフトウェアアーキテクチャの基礎 第2版 ―エンジニアリングに基づく体系的アプローチ作者:Mark Richards,Neal Ford,島田 浩二（翻訳）オーム社Amazon作る、試す、正す。　アジャイルなモノづくりのための全体戦略作者:市谷 聡啓ビー・エヌ・エヌAmazon]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[Bloom Filter -- 確率的データ構造の理論と実践]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/15/151146</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/15/151146</guid>
            <pubDate>Fri, 15 May 2026 06:11:46 GMT</pubDate>
            <content:encoded><![CDATA[はじめにRocksDBのソースを読んでいて、SSTableごとにBloom filterが挟まっている箇所に出くわした。概念は知っていたが、パラメータの導出を自分で追ったことはなかった。数式を眺めるだけでは全然理解できない悲しい子なので、Rustでゼロから書いて理論値と実測値を突き合わせることにした。結果として、ハッシュ関数の設計で半日つまずくことになる。github.com「ないもの」を探すコストデータベースに対する読み取りの大半は、存在しないキーへのアクセスだったりする。LSM-treeベースのストレージエンジンでは、この問題が顕著になる。Memtableにない、最新のSSTableにない、その前のSSTableにもない。最終的にディスク上の全SSTableを走査しなければ「存在しない」と結論づけられない。Bloom filterは「確実にない」を高速に判定する。偽陽性（存在しないのに「ある可能性がある」と答える）は許容するが、偽陰性（存在するのに「ない」と答える）は決して起こさない。この非対称性が、存在しないキーへの無駄なディスクアクセスを防ぐ。ビット配列とハッシュ関数原理は単純だ。m個のビット配列を用意し、すべて0で初期化する。要素を追加するとき、k個の独立なハッシュ関数で位置を計算し、そのビットを1にする。問い合わせ時、k個すべてのビットが1なら「たぶんある」、1つでも0なら「確実にない」。問題はk個の独立なハッシュ関数をどう用意するかだ。実用的には、2つのハッシュ値から残りを導出するdouble hashing技法を使う。Kirsch & Mitzenmacherが2004年に示したように、h_i(x) = h1(x) + i * h2(x) で十分な独立性が得られる。論文名は "Less Hashing, Same Performance: Building a Better Bloom Filter"。use std::collections::hash_map::DefaultHasher;use std::hash::{Hash, Hasher};fn double_hash<T: Hash>(item: &T) -> (u64, u64) {    let mut hasher1 = DefaultHasher::new();    item.hash(&mut hasher1);    let h1 = hasher1.finish();    let mut hasher2 = DefaultHasher::new();    h1.hash(&mut hasher2);    item.hash(&mut hasher2);    let h2 = hasher2.finish();    // h2を奇数にしてビット配列全体を均等にカバーする    (h1, h2 | 1)}この| 1を最初は書いていなかった。h2が偶数になると、ビット配列のサイズによっては半分のスロットにしか到達しない。n=100程度の小さなフィルタで偽陽性率が理論値の倍近くに膨れ上がり、原因を特定するまでに数時間かかった。ビット演算ひとつで分布が壊れるという、地味だが致命的な罠だ。ここではRust標準ライブラリのDefaultHasherを使っている。ただし、公式ドキュメント上は内部アルゴリズムが安定仕様ではなく、リリースをまたいで同じハッシュ値になる保証もない。教育用の実装なら十分だが、本番のBloom filterではxxhash-rustやahashのように性能と分布特性を明示して選べるハッシュ関数を使う方がよい。最適なパラメータの数学n個の要素を格納するBloom filterで、偽陽性率pを目標にする。このとき、最適なビット数mとハッシュ関数数kは以下で決まる。記号だけ並ぶと悲しい気持ちになるので、先に対応を置く。n: 入れたい要素数p: 許容する偽陽性率m: 必要なビット数k: 使うハッシュ関数の数つまり「何件入れるか」と「どれくらい誤判定を許すか」を決めると、必要なメモリ量とハッシュ回数が決まる。最適ビット数: m = -n * ln(p) / (ln2)^2最適ハッシュ数: k = (m/n) * ln2偽陽性率の理論値: p = (1 - e^(-kn/m))^kこの式は、各ビットが独立に一様な確率でセットされるという仮定に基づく。double hashingでは厳密には独立ではないが、実用上は十分に近い値を示す。fn optimal_num_bits(n: usize, fp_rate: f64) -> usize {    let m = -(n as f64) * fp_rate.ln() / (2.0_f64.ln().powi(2));    m.ceil() as usize}fn optimal_num_hashes(m: usize, n: usize) -> u32 {    let k = (m as f64 / n as f64) * 2.0_f64.ln();    let k = k.round() as u32;    k.max(1)}n=1000、p=0.01で計算すると、m=9586ビット（約1.2KB）、k=7。要素1つあたり約9.6ビットしか使わない。ただし、この計算はnの見積もりが正確であることを前提としている。想定以上に要素を追加するとビットが飽和し、偽陽性率が急激に上昇する。しかもBloom filterは元の要素を保持していないので、リサイズ（全要素の再挿入）は原理的に不可能だ。設計時のn見積もりをどこまで信頼できるかが、Bloom filterを使うかどうかの判断基準になる。Rust実装ビット配列はVec<u64>で手実装する。64ビット単位で管理することで、CPUのワードサイズに合わせた効率的な操作ができる。pub struct BloomFilter {    bits: Vec<u64>,    num_bits: usize,    num_hashes: u32,}impl BloomFilter {    pub fn new(expected_items: usize, fp_rate: f64) -> Result<Self, BloomFilterError> {        if expected_items == 0 {            return Err(BloomFilterError::ZeroItems);        }        if fp_rate <= 0.0 || fp_rate >= 1.0 {            return Err(BloomFilterError::InvalidFpRate(fp_rate));        }        let num_bits = optimal_num_bits(expected_items, fp_rate);        let num_hashes = optimal_num_hashes(num_bits, expected_items);        let words = num_bits.div_ceil(64);        Ok(Self {            bits: vec![0u64; words],            num_bits,            num_hashes,        })    }    pub fn insert<T: Hash>(&mut self, item: &T) {        let (h1, h2) = double_hash(item);        for i in 0..self.num_hashes {            let idx = self.get_index(h1, h2, i);            let word = idx / 64;            let bit = idx % 64;            self.bits[word] |= 1u64 << bit;        }    }    pub fn contains<T: Hash>(&self, item: &T) -> bool {        let (h1, h2) = double_hash(item);        for i in 0..self.num_hashes {            let idx = self.get_index(h1, h2, i);            let word = idx / 64;            let bit = idx % 64;            if self.bits[word] & (1u64 << bit) == 0 {                return false;            }        }        true    }    fn get_index(&self, h1: u64, h2: u64, i: u32) -> usize {        let combined = h1.wrapping_add((i as u64).wrapping_mul(h2));        (combined % self.num_bits as u64) as usize    }}wrapping_addとwrapping_mulはオーバーフローをラップアラウンドで処理する。ハッシュ計算では意図的な挙動で、modulo演算で範囲内に収めるから問題ない。公開APIとして見ると、エラー型も雑にしたくない。この記事の検証crateはedition = "2024"で、エラー型はthiserrorと#[non_exhaustive]を使う形にした。ライブラリ利用者がmatchを書く余地を残しつつ、後からvariantを追加してもSemVer上の破壊を避けやすい。#[derive(Debug, thiserror::Error)]#[non_exhaustive]pub enum BloomFilterError {    #[error("expected_items must be > 0")]    ZeroItems,    #[error("num_bits must be > 0")]    ZeroBits,    #[error("num_hashes must be > 0")]    ZeroHashes,    #[error("fp_rate must be in (0, 1), got {0}")]    InvalidFpRate(f64),}この実装にはいくつかの限界がある。insertが&mut selfを要求するため、マルチスレッド環境ではMutexやRwLockで囲む必要がある。ビット単位のアトミック操作でロックフリーにする実装も考えられるが、複雑さが跳ね上がる。また、ビット配列のサイズは構築時に固定されるので、要素数の見積もりを外すと偽陽性率が急激に悪化する。本番環境ではScalable Bloom FilterやPartitioned Bloom Filterの検討が必要になるだろう。理論値 vs 実測値n=1,000 / 10,000 / 100,000で偽陽性率を計測した。各nについて、n個を挿入した後、挿入していないn*10個のキーで偽陽性を数える。 n  理論値  実測値  ビット数  ハッシュ数  メモリ  1,000  0.0100  0.0112  9,586  7  1.2KB  10,000  0.0100  0.0100  95,851  7  11.7KB  100,000  0.0100  0.0100  958,506  7  117KB n=1,000では実測値がわずかに高い。これはサンプル数の少なさによる統計的ばらつきの範囲だ。nが大きくなるにつれて理論値とほぼ一致する。要素あたりのビット数は一定（約9.6bit）で、スケーラビリティは線形だと確認できる。理論値と実測値がここまで一致するとは正直予想していなかった。double hashingは厳密には独立なハッシュ関数ではないから、偽陽性率が理論値の1.5倍程度には膨らむだろうと予想していた。Kirsch & Mitzenmacherの証明が正しいことを自分の手で確認できたのは、論文を読むだけでは得られない納得感がある。HashSetとの比較では、n=10,000の場合にBloom filterは約12KB、HashSetは推定720KBで、約60倍のメモリ効率差がある。もちろん代償として偽陽性を受け入れている。Counting Bloom Filter標準のBloom filterは要素を削除できない。たとえばcompaction後にSSTableが消える場面を考えると、対応するフィルタのエントリも消したくなる。RocksDB自体はSSTable単位でフィルタを再構築するので実際にはCountingが必要になる場面は限られるが、削除可能なフィルタの仕組みを知っておく意味はある。ビットを0に戻すと、同じビットを共有する他の要素まで消えてしまう。格納した要素の列挙もできない。「フィルタに何が入っているか」を知る方法がないので、デバッグ時にこれが地味に困る。削除が必要な場面では、Counting Bloom filterがビットをカウンタに置き換えることで対処する。pub struct CountingBloomFilter {    counters: Vec<u8>,    num_slots: usize,    num_hashes: u32,}impl CountingBloomFilter {    pub fn insert<T: Hash>(&mut self, item: &T) {        let (h1, h2) = double_hash(item);        for i in 0..self.num_hashes {            let idx = self.get_index(h1, h2, i);            self.counters[idx] = self.counters[idx].saturating_add(1);        }    }    pub fn remove<T: Hash>(&mut self, item: &T) -> bool {        let (h1, h2) = double_hash(item);        let mut indices = Vec::with_capacity(self.num_hashes as usize);        for i in 0..self.num_hashes {            let idx = self.get_index(h1, h2, i);            if self.counters[idx] == 0 {                return false;            }            indices.push(idx);        }        for idx in indices {            self.counters[idx] = self.counters[idx].saturating_sub(1);        }        true    }}saturating_addでカウンタオーバーフローを防いでいる。u8なので最大255。同じスロットに256個以上の要素がマップされるとカウンタが飽和し、以降の削除で不整合が起きる。実用上は4ビットカウンタ（最大15）でも十分とされるが、u8を使うことでメモリ効率より安全性を取った。代償はメモリだ。標準Bloom filterの8倍（1bitが1byteになる）。削除が必要な場面でのみ使う。Cuckoo FilterとXor FilterBloom filterの後継として、Cuckoo FilterとXor Filterが注目されている。Cuckoo Filterは要素の削除をサポートし、Bloom filterより少ないメモリで同等の偽陽性率を達成する。Fan et al.の"Cuckoo Filter: Practically Better Than Bloom"（2014）で提案された。Cuckoo hashingの空きスロット探索を応用した構造で、フィンガープリント（要素のハッシュの一部）をバケットに格納する。Xor Filter（2020年提案）はさらに激しい。構築時に全要素が既知であれば、Bloom filterの半分以下のメモリで同じ偽陽性率を実現する。構築は遅いが、参照は高速。静的なデータセット（SSTableのインデックスなど）には有力な選択肢だ。現実のシステムでの使われ方RocksDBはSSTableごとにBloom filterを持ち、キーが含まれないSSTableへのディスクアクセスをスキップする。デフォルトは10 bits/keyで、偽陽性率は約1%。RocksDBのBloom Filter wikiにパラメータ選択の詳細がある。PostgreSQLはbloomインデックスアクセスメソッドを提供している。複数カラムの組み合わせ検索で、個別のB-treeインデックスを作るよりコンパクトになる場面がある。Cassandraはパーティションキーに対するBloom filterを各SSTableに持つ。読み取りパスでの最適化として不可欠な位置づけだ。ChromeのSafe Browsingは、Bloom filterの設計思想がよく表れている事例だ。ユーザーがアクセスする全URLをGoogleのサーバーに送信すれば正確な判定はできる。しかしそれは閲覧履歴の全量送信であり、プライバシーの観点で受け入れがたい。代わりに、既知の悪意あるURLのBloom filterをローカルに持たせる。フィルタを通過したURLだけがサーバーに問い合わせられる。偽陽性があっても、それはサーバーへの余分な問い合わせが少し増えるだけで、偽陰性（危険なURLを見逃す）は起きない。Bloom filterの非対称性がプライバシーとセキュリティの両立を可能にしている。現代のWebサービスでどう考えるかBloom filterをWebサービスに入れるかどうかは、「間違え方」を先に決める判断だ。キャッシュミスの手前、存在しないIDへのAPIアクセス、ログやイベントの重複候補、レコメンド候補の既読判定のように、偽陽性が余分な後段処理で済む場所では強い。一方で、権限チェック、課金可否、在庫確保のように「ある」と誤判定しただけでユーザー影響が出る場所には置かない。Rustで実装するときは、確率パラメータを生のf64で散らさず、構築時に検証してResultで返す。ハッシュ関数もDefaultHasherの偶然の安定性に依存しない。Webサービスの部品として使うなら、メトリクスに偽陽性率、ビット飽和率、想定要素数との差分を出す。Bloom filterは一度作るとリサイズできないので、運用で「想定したnを超えた」ことに気づける設計が必要になる。完璧な答えの代わりに、ほぼ正しい答えを返す。その代償が1要素あたり10ビット。この取引を受け入れられる場面が、システムの中には驚くほど多い。Bloom filterを知っていると、「この検索、本当に全件走査する必要があるのか」と問い直す習慣がつく。その問いかけ自体に価値がある。]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[ Rust のホットリロード、2026年の現在地 — hot-lib-reloader / Dioxus / Leptos を実際に動かして比較した]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/14/230047</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/14/230047</guid>
            <pubDate>Thu, 14 May 2026 14:00:47 GMT</pubDate>
            <content:encoded><![CDATA[はじめに「Rust はコンパイルが遅いから」。技術選定の議論で、私はこの一言で打ち切られたことが何度かある。性能でも安全性でもなく、開発時のフィードバックループの遅さだ。コードを書いて保存したら画面に反映される、というサイクルが Rust にはない、と。数年前ならその通りだと頷いていた。2026年5月現在、それはもう正確ではない。少なくとも、そう反論できる材料が3つは揃っている。hot-lib-reloader (v0.8.2) — 動的ライブラリ（dylib）を実行中のプロセスに差し替える汎用的なアプローチDioxus (v0.7.4) — RSX テンプレートのパッチ＋ Subsecond による Rust コードのホットパッチLeptos (v0.8.17) — view テンプレートパッチ＋ CSS ホットリロードこの記事では、3つすべてを実際にセットアップし、「ソースコードを変更 → 保存 → 反映」のサイクルを回して検証した。何がどこまで動くのか、どこで壊れるのか、現時点でどういうトレードオフがあるのか。自分の環境で確かめた結果を示す。検証に使ったコードはすべて公開している。github.com検証環境macOS (Apple Silicon, M3)Rust 1.94.1 stableエディタ: nvim + rust-analyzerPart 1: hot-lib-reloader — 動的ライブラリ方式仕組みhot-lib-reloader のアプローチは比較的シンプルで、ロジックを dylib（動的ライブラリ）としてビルドし、アプリケーション本体が libloading 経由でそれをロードする。ファイル変更を検知したら dylib を再ビルドし、ロード済みのライブラリを新しいものに差し替える仕組みになっている。フレームワーク非依存なので、ゲーム、CLI ツール、デスクトップアプリなど、幅広い用途に使える。github.comセットアップワークスペースを2つのクレートに分ける。lib がホットリロード対象の dylib、app がそれをロードするバイナリだ。part1-hot-lib-reloader/├── app/          # bin クレート│   ├── Cargo.toml│   └── src/main.rs└── lib/          # dylib クレート（ホットリロード対象）    ├── Cargo.toml    └── src/lib.rslib 側の Cargo.toml で重要になるのが crate-type の指定だった。[lib]crate-type = ["rlib", "dylib"]dylib だけだとハマるポイントがある。app が hot_lib::State 型を使うために lib を依存に持つが、dylib だけだと std のシンボルが重複してリンクエラーになってしまう。rlib を併記することで、型定義は静的リンク、関数呼び出しは動的ロードという使い分けができるようになった。lib 側の関数には #[unsafe(no_mangle)] を付ける。Rust edition 2024 では旧来の #[no_mangle] はコンパイルエラーになる。#[unsafe(no_mangle)]pub fn step(state: &mut State) {    state.counter += 1;    state.message = format!("counter = {} (try changing this string!)", state.counter);}#[unsafe(no_mangle)]pub fn render(state: &State) -> String {    format!("[hot-lib] {}", state.message)}app 側では #[hot_lib_reloader::hot_module] 属性マクロを使う。v0.8.0 で API が変わっており、旧来の hot_lib_reloader! マクロは廃止されている。#[cfg(feature = "reload")]#[hot_lib_reloader::hot_module(    dylib = "hot_lib",    lib_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/debug"),    file_watch_debounce = 300)]mod hot_lib {    pub use hot_lib::State;    #[hot_functions]    extern "Rust" {        pub fn step(state: &mut State);        pub fn render(state: &State) -> String;    }    #[lib_change_subscription]    pub fn subscribe() -> hot_lib_reloader::LibReloadObserver {}}lib_dir の指定には注意が必要だった。デフォルトではクレート自身の target/debug を見に行くが、ワークスペース構成ではビルド成果物はワークスペースルートの target/debug に出力される。concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/debug") で正しいパスを指す必要があった。実行と検証ターミナルを2つ使う。# ターミナル1: lib を監視して自動リビルドcargo watch -w part1-hot-lib-reloader/lib -x 'build -p hot-lib'# ターミナル2: アプリを実行cargo run -p hot-app起動するとアプリは最初のイテレーションを実行し、dylib の変更を待機する。=== hot-lib-reloader demo ===Mode: HOT RELOAD enabled (change lib/src/lib.rs and save)[iter 1] [hot-lib] counter = 1 (try changing this string!)[iter 1] Waiting for lib change... (Ctrl+C to exit)ここで lib/src/lib.rs を編集する。state.counter += 1 を state.counter += 100 に変え、フォーマット文字列も変更して保存する。cargo-watch がリビルドを検知し、hot-lib-reloader が新しい dylib をロードする。  -> reloaded![iter 2] [hot-lib] HOTRELOAD OK! counter = 101counter は 1 のまま保持され（アプリを再起動していないので当然ではあるが）、新しいロジック += 100 が適用されて 101 になった。フォーマット文字列も変わっている。実際に動いているのを見ると、やはり嬉しい。型変更時の挙動 — 最も重要な制約ロジック変更は問題なく動いた。では、State 構造体にフィールドを追加したらどうなるだろうか。pub struct State {    pub counter: i64,    pub message: String,    pub extra: f64,  // ← 追加}結論から言うと、今回の検証では segfault しなかった。ただし、これは安全だということではない。追加した extra フィールドは構造体の末尾に配置され、step() 関数は extra に一切アクセスしない。つまり、メモリレイアウトの不一致は存在するが、不正なメモリ領域への読み書きが偶然発生しなかっただけだ。これは未定義動作であり、次に動かしたときに壊れる可能性がある。 フィールドの順序変更、型の変更、あるいは新フィールドにアクセスするロジックを追加した瞬間に segfault する。シリアライズ回避パターン型変更が未定義動作になるのは、dylib 境界で構造体のポインタを直接共有しているからだ。ならば、構造体ではなく文字列でやり取りすれば、レイアウトの不一致は問題にならない。状態を serde_json::Value 経由でシリアライズし、dylib 境界を JSON 文字列で渡すパターンがある。#[unsafe(no_mangle)]pub fn step_serialized(state_json: &str) -> String {    let result: Result<State, _> = serde_json::from_str(state_json);    match result {        Ok(mut state) => {            step(&mut state);            serde_json::to_string(&state)                .unwrap_or_else(|e| format!(r#"{{"error":"serialize failed: {}"}}"#, e))        }        Err(e) => {            eprintln!("[hot-lib] deserialization failed, resetting state: {e}");            let mut state = State::new();            step(&mut state);            serde_json::to_string(&state)                .unwrap_or_else(|e| format!(r#"{{"error":"serialize failed: {}"}}"#, e))        }    }}型が変わってデシリアライズに失敗したら、デフォルト状態にリセットして続行する。segfault ではなく安全な縮退になる。パフォーマンスのオーバーヘッドはあるが、開発時のホットリロード用途なら許容範囲だ。feature flag による切り替え本番ビルドでは dylib ではなく通常の静的リンクを使いたい。feature flag で切り替える。[features]default = ["reload"]reload = ["dep:hot-lib-reloader"]# 開発時（ホットリロード有効）cargo run -p hot-app# 本番ビルド（静的リンク）cargo run -p hot-app --no-default-featuresまとめ：hot-lib-reloader の評価動くもの: 関数本体のロジック変更、フォーマット文字列の変更、表示内容の変更動かないもの: 関数シグネチャの変更、構造体のレイアウト変更（未定義動作）、ジェネリック関数Developer Experience（DX）: ターミナル2つ必要（cargo-watch + アプリ）。リロードまでの時間は dylib のビルド時間に依存（小規模なら1秒未満）使いどころ: ゲームループ、TUI アプリ、シミュレーション。フレームワーク非依存なのが最大の強みhot-lib-reloader はフレームワーク非依存という強みがある一方、テンプレート系の即時パッチには対応していない。UI フレームワークはこの領域でどこまで進んでいるのか。Part 2: Dioxus 0.7 — RSX パッチ + Subsecond仕組みDioxus のホットリロードは3層構造になっている。RSX ホットリロード: テンプレート（RSX マクロ内の HTML 構造。React の JSX に相当する Dioxus 独自のマクロ記法）の変更を、リコンパイルなしでブラウザに即座にパッチするCSS/アセットリロード: CSS ファイルの変更を WebSocket 経由でブラウザに送るSubsecond ホットパッチ (--hotpatch): Rust コード本体（関数のロジック）を実行中のプロセスにパッチする。リビルドは走るが、フルリンクではなく差分パッチdioxuslabs.comセットアップ[package]name = "part2-dioxus"version = "0.1.0"edition = "2024"[dependencies]dioxus = { version = "0.7.4", features = ["web"] }features = ["web"] を忘れると dx serve がターゲットトリプルを自動検出できずにエラーになる。Dioxus.toml も必要になる。[web.resource.dev] セクションは空でも存在させておく必要がある。[application]name = "part2-dioxus"[web.app]title = "Dioxus Hot-Reload Demo"[web.resource]style = ["assets/main.css"][web.resource.dev]RSX ホットリロードの検証cd part2-dioxusdx serve1.03秒でビルドが完了し、http://127.0.0.1:8080 でサーバーが起動する。Build completed successfully in 1.03s, launching app!ここで src/main.rs の h1 テキストを変更して保存する。Hotreloading: /src/main.rsフルリビルドは走っていない。 RSX テンプレートの差分だけがブラウザに送られ、画面が即座に更新された。体感では数十ミリ秒程度で、React の HMR に近い速さだ。RSX ホットリロードで反映されるもの:テキストの変更要素の追加・削除属性値の変更（クラス名、スタイル）フォーマット文字列内の変数位置の移動for ループ / if ブロック内の要素変更反映されない（フルリビルドが必要な）もの:新しい変数の定義use_signal などの hooks の追加コンポーネントの props 型変更Rust のロジック変更（関数本体の計算式など）Subsecond ホットパッチの検証Subsecond は Dioxus 0.7 で組み込まれた実験的機能だ。冒頭で触れた通り、変更された関数のマシンコードを実行中のバイナリにパッチする。hot-lib-reloader が dylib 全体を差し替えるのに対し、Subsecond は関数単位で差し替える。リビルドは走るが、フルリンクを省略して差分だけを送るため高速だ。dx serve --hotpatch起動時に fat linking が走るため、通常の dx serve より少し遅い（1.92秒 vs 1.03秒）。fat linking は全シンボルの位置情報を保ったままリンクする処理で、後から関数本体だけを差し替えられる状態を作っておくために必要になる。compute() 関数のロジックを n * n から n * n * n + 42 に変更して保存する。Hot-patching: part2-dioxus/src/main.rs took 882ms882ミリ秒でパッチが適用された。 フルリビルドではなく、変更された関数だけが差し替えられているようだ。1秒を切っているのは、個人的には十分実用的に感じた。RSX の変更は --hotpatch モードでも引き続き即時反映される。Hotreloading: ログが出る。RSX パッチと Subsecond は両立して動く。ただし fat linking のぶん起動は遅くなるので、UI だけを触る作業なら --hotpatch なしの方が軽い。CSS ホットリロードassets/main.css の --accent カラーを変更して保存する。サーバーログに明示的なメッセージは出ないが、ブラウザ側で即座に色が切り替わる。ログを出さずに WebSocket 経由で配信されている。注意点: RUSTFLAGS環境に RUSTFLAGS="-C target-cpu=native" が設定されていると、wasm32 ビルドが __wbindgen_externref_table_alloc エラーで失敗する。target-cpu=native はホスト CPU 向けの最適化フラグだが、wasm32 ターゲットに適用されると reference-types feature を無効にしてしまう。reference-types は wasm-bindgen の externref サポートに必要で、これが落ちるとビルドが通らない。RUSTFLAGS="" dx serveで回避できる。.cargo/config.toml に [target.wasm32-unknown-unknown] の rustflags を設定しても、環境変数 RUSTFLAGS が設定されている場合は無視される（Cargo の仕様）。まとめ：Dioxus 0.7 の評価RSX パッチ: 安定。テンプレートの変更が数十ミリ秒で反映される。開発体験は React の HMR と同等Subsecond: 実験的だが動いた。Rust ロジック変更が 882ms で反映。「Rust はコンパイルが遅い」という印象を更新する数字だCSS リロード: ログを出さずに動作。特に問題は見つからなかったセットアップの手間: dx CLI 一発。今回試した3つの中では最も簡潔使いどころ: Web UI / デスクトップ UI / モバイルアプリ。Dioxus のエコシステムに乗れるなら有力な選択肢Dioxus は独自の RSX テンプレート + Subsecond で強力なホットリロード体験を実現していた。では、もうひとつの主要な Rust Web フレームワークである Leptos はどうか。Part 3: Leptos 0.8 — view パッチ + CSS リロード仕組みLeptos のホットリロードは2層構成になっている。view テンプレートパッチ: view! マクロ内の HTML 構造変更を、cargo leptos watch --hot-reload で即座にパッチCSS ホットリロード: CSS ファイル変更の即時反映Subsecond 統合も subsecond feature フラグで利用可能だが、まだ初期段階のようだ。ただし、view テンプレートパッチは SSR（Server-Side Rendering）構成でのみ利用可能だ。 今回の検証では、セットアップが簡便な CSR（Client-Side Rendering）+ trunk 構成を使った。trunk は view パッチではなく WASM フルリビルド＋自動リロードで変更を反映する方式になる。SSR 構成での view パッチについては検証結果の後に補足する。セットアップ[package]name = "part3-leptos"version = "0.1.0"edition = "2024"[dependencies]leptos = { version = "0.8.17", features = ["csr"] }console_error_panic_hook = "0.1"index.html では CSS リンクに data-trunk 属性を付ける。trunk のアセットパイプラインで処理するために必要になる。<link data-trunk rel="css" href="style/main.css" />trunk serve での検証cd part3-leptostrunk serve本サンプルでは Trunk.toml でポートを 8090 に設定している（trunk のデフォルトは 8080）。📦 starting build    Finished dev profile in 0.14s✅ success📡 server listening at http://127.0.0.1:8090/src/main.rs の h1 テキストを変更して保存する。📦 starting build   Compiling part3-leptos v0.1.0    Finished dev profile in 2.14s✅ successtrunk はフル WASM リコンパイルを走らせる。2.14秒かかった。Dioxus の RSX パッチ（数十ミリ秒）や Subsecond（882ms）と比べると遅い。それでも trunk のファイルウォッチャーがブラウザを自動リロードするので、手動で F5 を押す必要はない。保存から反映まで約2秒なら、開発の流れは途切れない。CSS の変更は別で、style/main.css の --accent カラーを変更して保存してみる。📦 starting build    Finished dev profile in 0.19s✅ successRust のリコンパイルは走らず、アセットの再バンドルだけで 0.19秒。ほぼ即時だ。cargo-leptos での view ホットリロードcargo-leptos は SSR + WASM 構成を前提としたフルスタックビルドツールで、[package.metadata.leptos] セクション、ssr feature、サーバーバイナリの定義などを要求する。今回採用した trunk + CSR 構成では cargo-leptos のスコープ外であり、--hot-reload は動かない。以下は公式ドキュメントベースの記述で、手元では未検証。SSR 構成で cargo leptos watch --hot-reload を使うと、Leptos は Dioxus と同様の view テンプレートパッチを提供する。view! マクロ内の HTML 変更がリコンパイルなしで反映される。Subsecond 統合Leptos 0.8.17 は subsecond feature フラグで Dioxus 由来の Subsecond エンジンを統合している。leptos = { version = "0.8.17", features = ["csr", "subsecond"] }ただし、これはまだ初期段階で、CSR モード単体での動作確認は必要だ。Dioxus の --hotpatch のような「CLI 一発で動く」体験には、まだ届いていない。まとめ：Leptos 0.8 の評価trunk + CSR: WASM フルリコンパイル（~2秒）＋自動リロード。React の HMR ほどではないが実用的CSS リロード: 0.19秒。問題なしcargo-leptos + SSR: view テンプレートパッチが使える（SSR 構成が必要）Subsecond: feature フラグあり。Dioxus との共通基盤だが、まだ初期段階使いどころ: Web アプリケーション。SSR/SSG が必要な場合。Actix/Axum ベースのフルスタック構成横断比較 観点                hot-lib-reloader 0.8.2    Dioxus 0.7.4                       Leptos 0.8.17                                      対象領域            汎用（ゲーム、CLI 等）    UI フレームワーク全般              Web フレームワーク                                 テンプレート変更    N/A                       RSX パッチ（~ms）                  view パッチ（SSR 時）/ フルリビルド（CSR 時 ~2s）  ロジック変更        dylib リロード（~1s）     Subsecond（~882ms）                Subsecond（初期段階）                              CSS 変更            N/A                       サイレントリロード                 0.19s（再バンドル）                                状態保持            手動管理（保持される）    自動（RSX）/ 要確認（Subsecond）   自動（view パッチ）/ N/A                           型変更への耐性      ❌（UB / segfault）       N/A（RSX）/ 制限あり（Subsecond）  N/A                                                stable Rust         ✅                        ✅                                 ✅                                                 セットアップの手間  中（ワークスペース分離）  低（dx CLI 一発）                中（trunk or cargo-leptos）                        成熟度              安定                      RSX 安定 / Subsecond 実験的        view 安定（SSR）/ Subsecond 初期                  どれを選ぶか今回の検証を踏まえて、用途別に整理する。最後に私自身の選択も書く。ゲーム / デスクトップアプリ / フレームワーク非依存: hot-lib-reloader。Bevy や macroquad のゲームループ、egui のデスクトップアプリなど、幅広い場面に適用できる。型変更の制約は残るが、開発時のフィードバックループは確実に改善される。Web UI / クロスプラットフォーム UI: Dioxus。RSX パッチは安定しており、Subsecond は実験的だが実用に耐える。React ライクな API に馴染みがあるなら移行コストも低い。Web アプリ（SSR/SEO 必須）: Leptos。SSR ファーストの設計、Actix/Axum との統合、Islands アーキテクチャ。ホットリロードは cargo leptos watch --hot-reload で view パッチが使える。Subsecond 統合が成熟すれば、さらに強くなる。「とにかくすぐ試したい」: dx serve --hotpatch。セットアップが最も簡潔で、テンプレートもロジックも即座に反映される。私自身は、明日 Rust で新規 UI を書くなら Dioxus を取る。Subsecond の 882ms と RSX パッチの両方が同じ CLI 一発で動く体験は、現時点で頭一つ抜けている。ゲームや CLI なら hot-lib-reloader、ただし状態は必ず serde 経由で渡す。型変更の UB を踏むリスクと、開発時のフィードバックループを天秤にかければ、シリアライズのコストは安い方だ。おわりに3つ動かしてみて、印象が一番更新されたのは Dioxus の Subsecond だった。882ms。React の HMR と並ぶ秒数を、Rust のコードに対して出してきた。「Rust はコンパイルが遅い」を反証する一行目に、私はこの数字を使うことにする。一方で、技術的に記憶に残ったのは hot-lib-reloader の型変更テストで segfault しなかったことの方だった。動いた、で済ませてはいけない場面で動いてしまう。dylib 境界を直接ポインタで跨ぐかぎり、これは消えない。使うなら serde 経由でシリアライズする、を私はデフォルトに置く。未定義動作が「たまたま動く」のは、「常に壊れる」より厄介だと思う。Rust のホットリロードはまだ発展途上だ。それでも、もう「存在しない」と言って打ち切ることはできない。次にあの一言で議論を切られそうになったとき、この記事の数字を覚えていてほしい。それが反論の出発点になる。参考リンクhot-lib-reloader — Robert Krahn による汎用ホットリロードクレートDioxus Hot-Reload ドキュメント — 公式ドキュメントSubsecond — Dioxus チームによるランタイムホットパッチエンジンLeptos — Greg Johnston による Rust Web フレームワークHot Reloading Rust (Robert Krahn) — hot-lib-reloader の設計解説Hot Reloading Rust for Game Dev (Ryan Goldstein) — ゲーム開発向けホットリロード]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[AIに対する不満を無くす方法 ー いいスキルの活用方法と見つけ方]]></title>
            <link>https://speakerdeck.com/aminevg/ainidui-surubu-man-wowu-kusufang-fa-iisukirunohuo-yong-fang-fa-tojian-tukefang</link>
            <guid isPermaLink="false">https://speakerdeck.com/aminevg/ainidui-surubu-man-wowu-kusufang-fa-iisukirunohuo-yong-fang-fa-tojian-tukefang</guid>
            <pubDate>Thu, 14 May 2026 04:00:00 GMT</pubDate>
            <author>Amine Ilidrissi</author>
        </item>
        <item>
            <title><![CDATA[続・完結編 — 「完結」と書いた翌週に Layer 2 の thinking を切ったら 5 機種ハイブリッドが 70〜85% 速くなって、正答率はほぼ動かなかった話]]></title>
            <link>https://shu-kob.hateblo.jp/entry/2026/05/13/095638</link>
            <guid isPermaLink="false">https://shu-kob.hateblo.jp/entry/2026/05/13/095638</guid>
            <pubDate>Wed, 13 May 2026 00:56:38 GMT</pubDate>
            <content:encoded><![CDATA[はじめに前回（Anthropic Claude (Sonnet 4.6 / Opus 4.7) を加えて、5 機種で自転車青切符ベンチマークを総括する）でタイトルに堂々と「完結編」と書きました。完結しませんでした。正確に言うと、判定の質の話は前回で出し切ったつもりだったのですが、前回までの計測にずっと 1 個飲み込みが悪かった点が残っていました。Claude Sonnet 4.6 のハイブリッドが 7 問通しで 226 秒、Gemini 3 Flash に至っては 629 秒かかっていた、あの数字です。Layer 1（条文 AST + TF-IDF + 反則金テーブル）で根拠を全部渡しているのに、Layer 2 の LLM がなぜそんなに考え込むのか、設計上は説明がつきません。ブログを公開して数日経ってから「これ、判定の話に集中しすぎてレイテンシを放置していたのでは」と気づき、潰しに行きました。結論は身も蓋もなくて、Gemini も Claude もデフォルトで thinking を回す設定になっていて、Layer 1 で根拠を全部注入しているハイブリッド構成ではそのコストが丸ごと無駄になっていた、という話でした。明示的に切ってやると Flash は 1 リクエストあたり 5 分 → 数秒、ハイブリッド全体で 7〜8 割の時間が消えます。問題は その代償で正答率は落ちないのか という点で、これを 5 機種すべてで再計測したのが今回の検証です。「完結編」と書いた手前、結論が変わったら書き直す責任があるので、続編を書きます。これまでのシリーズはこちら。Flash 編：単体 5/7、卒論ハイブリッドで 6/7Pro 編：単体は Flash と同じ 5/7、最後の一歩で同じ方向に倒れるGemma3 編：ローカル 4B では条文を注入されても逆向きに倒れるClaude 編（自称・完結編）：Sonnet/Opus を加えて、ハイブリッドだと「Layer 1 が穴を開けたときの後始末」がベンダーで割れることを確認何が起きていたかハイブリッドの Layer 2 呼び出しは、こういう薄いプロンプトを Gemini に投げているだけです。system instruction：「Layer 1 が e-Gov XML からパースした条文と反則金を絶対的根拠として使え、学習データからの補完はするな」user contents：Layer 1 が引いた条文 3 本（テキストごと）＋ 反則金テーブル抜粋 ＋ 検出済み論理フラグ（exception, proviso, delegation など）＋ 委任規定の解決済み情報（施行令第 26 条で「70 歳以上の者」と展開済み）＋ 質問LLM 側がやることは、条文の階層を読み下して「合法 / 違反」を選び、注入された反則金を引き写し、reasoning を書く。これだけです。条文を探す必要も、金額を推測する必要もありません。ところが、デフォルト設定で投げると以下が走ります。Gemini 3 系：thinking_config を明示しないと、内部で「自分で考える」モードが入る。Pro は元から思考前提のモデルなのでまだ穏当ですが、Flash は単発で 5 分 thinking してから 1 行返してくる事象が出ました。Claude Opus 4.7 / Sonnet 4.6：旧来の温度パラメータが消えた代わりに adaptive thinking が前提化されている。SDK アダプタ（anthropic_client.py）でも、最初は良かれと思って Claude 系には常時 thinking: {type: "adaptive"} を付けて投げていました。これが今回の Sonnet 226 秒の犯人です。つまり、「考えなくていい仕事のために思考時間を全部払っていた」状態。Layer 1 を後段に持つハイブリッド構成では設計上ありえないコストでした。実装の修正は数行です。# backend/src/judgement/hybrid_judge.pyresponse = self._client.models.generate_content(    model=self._model,    contents=prompt,    config={        "system_instruction": HYBRID_SYSTEM_PROMPT,        "temperature": 0.0,        # Layer 1 で根拠条文・反則金・委任規定を全て注入済みのため thinking は不要。        # 有効のままだと Gemini 3 Flash で 1 リクエスト 5 分かかる (#10)。        "thinking_config": {"thinking_budget": 0},    },)Anthropic 側は逆方向の修正で、anthropic_client.py から「Opus/Sonnet を見たら自動で adaptive thinking を付ける」分岐を撤廃しました。config["thinking"] が呼び出し側から明示されたときだけ転送する素直な実装に直しています。# backend/src/llm/anthropic_client.py（修正後）thinking_cfg = config.get("thinking")if thinking_cfg:    kwargs["thinking"] = thinking_cfg実コードは PR #10 を参照ください。再発防止用のプロファイラ改善活動の副産物として、Layer 1 / Layer 2 を分けて計測する scripts/profile_hybrid.py を入れました。「総時間の何 % が LLM か」を出すだけのスクリプトですが、これがないと「遅いのは LLM か Layer 1 か」を毎回主観で判断することになるので、再発防止用として置いてあります。$ python -m scripts.profile_hybrid --model flash --runs 3 --queries 3[Summary] (mean over 9 samples)  layer1 (VSM+prompt) mean=  3.3ms    0.1%  min=  2.8ms  max=  4.1ms  llm_call           mean=  2.84s   99.9%  min=  2.31s  max=  3.51s  TOTAL              mean=  2.85s[Diagnosis]  支配項: LLM 呼び出し (100%). Layer 1 はほぼノイズ。Layer 1 はミリ秒オーダーで、結局すべてが Layer 2 のレイテンシで決まる、という当たり前の構造が見えます。LLM 側を整えれば全体が速くなる、という指針もここからきています。結果：5 機種を再計測python -m src.main --hybrid --model <model> を 5 機種で回し、Pre-fix（thinking on）と Post-fix（thinking_budget: 0 / Claude は adaptive thinking 撤廃）を並べました。Pre-fix の数字は前 4 編で使ったデータ、Post-fix は今週撮り直したものです。ハイブリッド 7 ケース合計時間 モデル  Pre-fix  Post-fix  削減率  Gemini 3 Flash  629 秒  146 秒  76.8%  Gemini 3.1 Pro  147 秒  22 秒  85.0%  Claude Sonnet 4.6  226 秒  63 秒  72.1%  Claude Opus 4.7  54 秒  53 秒  1.1%  Gemma3:4b（ローカル）  249 秒  120 秒  51.8% Pro が一番きれいに効きました。約 1/7 です。Layer 1 が条文と金額を全部渡している以上、Layer 2 は「整形係」でしかなく、Pro クラスのモデルの能力は思考時間ではなく 生成速度のほうで効く、という仮説どおりの結果になりました。Opus 4.7 だけは Pre-fix の時点ですでに 54 秒で、ここから 53 秒で誤差レベル。これは Opus の adaptive thinking が「資料が揃っているケースでは考える余地がないと自己判断する」挙動を持っていたためで、前回 Claude 編で「Opus が Sonnet より速い」と書いた件と整合しています。Opus は最初から自前で thinking を切っていたわけで、こちら側から強制オフしても伸び代が残っていませんでした。Gemma3:4b（ローカル）には thinking_budget のような概念がそもそもないので、ここで短縮された半分は別要因です。これはおそらく、Pre-fix の計測時に他のジョブと重なって Ollama サーバが重かった分が抜けただけで、今回の最適化由来ではありません。ここは差分として参考程度に。ハイブリッド 1 ケースあたり最大値平均より「最悪ケース」のほうがユーザ体験には響くので、ケース別の最大レイテンシも並べます。 モデル  Pre-fix max  Post-fix max  削減  Gemini 3 Flash  約 297 秒（commit 計測の単発値）  47.5 秒（TC-005）  84%  Gemini 3.1 Pro  約 22 秒  4.0 秒（TC-002）  82%  Claude Sonnet 4.6  61 秒（TC-001）  17.6 秒（TC-002）  71%  Claude Opus 4.7  約 10 秒  10.6 秒（TC-001）  横ばい  Gemma3:4b  約 33 秒  26.7 秒（TC-001）  19% Flash が Pre-fix で「1 リクエスト 5 分」というのは PR #10 のコミットメッセージにある単発計測（commit f98fcef）で、運用上の最悪値です。Post-fix では 47.5 秒（TC-005「酒気帯び」、論理フラグに delegation が乗っていてプロンプトが長め）が最大で、5 分問題は消えました。ただし、ここは正直に書くと、commit メッセージの「Flash 105× 高速化」は短いクエリ 3 本での best-case 値で、実際の 7 ケース通しでは Post-fix の平均でも 21 秒（最大 47 秒）残っています。「99% 削減」というラベルは最悪値どうしの比較で、平均値で測ると 77% くらいが実態です。誇張は避けたいので、両方の数字を出しておきます。速くなったのは分かった。精度はどうなったかここからが本題です。評価ルブリックは前回までと同じく、「判定（合法 / 違反）、根拠条文、反則金、すべて期待値と一致 = ✓、判定と条文は合うが金額抜け or 不一致 = △、判定そのものが誤り = ✗」。Pre-fix vs Post-fix の正答内訳 モデル  Pre 完全✓  Pre 本質✗  Post 完全✓  Post 本質✗  差分  Gemini 3 Flash  5/7  1 (TC-003 合法と誤判定)  5/7  0  改善：TC-003 が「違反」になった  Gemini 3.1 Pro  5/7  1 (TC-003 合法と誤判定)  5/7  0  改善：TC-003 が「判定不能」に退避  Claude Sonnet 4.6  5/7  0  5/7  1  退化：TC-002 で「合法」と誤判定  Claude Opus 4.7  5/7  0  5/7  0  同点  Gemma3:4b  3/7  4  3/7  4  同点 完全 ✓ の数字は 5 機種すべて変動ゼロ。ハイブリッド構成下では thinking をオフにしても、判定 + 条文 + 金額の正答数は動きません。Layer 1 がグラウンディングを取り切っているなら Layer 2 の思考時間に正答率は依存しない、というのは「そりゃそうだろう」という話ですが、実測で取れた意味は大きいと思っています。ただし、「本質的 ✗（judgement が逆方向）」では機種ごとに小さな動きが出ました。良くなった機種と悪くなった機種が両方あります。順に見ます。改善側：Gemini Flash と Pro が「合法と言ってしまう」事故を起こさなくなった前回 Claude 編で書いた、Layer 1 が穴を開けたときに Gemini 系が陥っていた False Negative。TC-003「自転車でスマホを手に持って運転」Layer 1 が引いた条文：制動装置・検査・自転車道（いずれも携帯使用とは無関係）Pre-fix Flash の判定：合法（「禁止規定がないので違反は問えない」）これが Post-fix では：Post-fix Flash の判定：違反（「条文データには該当なし、反則金特定不可」）Pro も同様で、Pre-fix の「合法」から Post-fix では「判定不能」へ。どちらも「合法だ」と断言する事故は消えました。これは正直予想外でした。thinking を切ったほうが判定が改善するというのは反直感的です。考えられる説明はこうです。Pre-fix で thinking が回ると、Gemini は内部で「条文に違反規定がない → 違反として扱う根拠がない → 違反ではない → 合法」という三段論法を組み立て、その結論を judgement フィールドに焼き込んでしまっていました。Post-fix の thinking なしでは、表層的に「条文範囲外」というシグナルだけ拾い、judgement を「違反」または「判定不能」と慎重側に倒します。思考時間を与えたほうが、与えないより悪い判断をする ケース。これはハイブリッドの設計と Layer 2 の自律的推論が衝突する例で、シリーズ通じて一番面白かった観察です。退化側：Claude Sonnet が TC-002 を取りこぼした逆方向に倒れた機種が一つあります。Claude Sonnet 4.6 の TC-002「成人 30 歳が標識のない歩道」。Pre-fix Sonnet の判定：違反（reasoning も整合：第63条の4第1項の三号列挙からどれにも該当しない）Post-fix Sonnet の判定：合法（reasoning は「条文上の許可根拠を欠く」と書きつつ、judgement だけ「合法」）reasoning と最終判定が矛盾しているので、フィールドレベルの整合性が崩れたとも言えます。adaptive thinking を撤廃した結果、Sonnet が長文 reasoning の途中で「許可根拠を欠く」と論理を積み上げたあと、最終フィールドに焼き込む段階で「反則金データに該当金額がない → 違反として確定できない → 合法」とジャンプしてしまったように見えます。Pro/Flash が Pre-fix で起こしていた「合法ジャンプ」が、Sonnet では Post-fix で出るようになった、という形。thinking がオンだと長文 reasoning とフィールド出力の整合性は保たれるが、オフだと最終フィールドが reasoning から離れる、という挙動差が Claude では出る、ということだと思います。Opus 4.7 ではこの事故は起きておらず（同じ TC-002 で「違反」と正しく返した）、ここは Opus と Sonnet で adaptive thinking 撤廃の影響度が分かれた点です。全体で見ると：完全 ✓ は不動、judgement レベルで小さな揺れ5 機種を並べてみての全体観です。 観点  結果  完全 ✓ 正答数  5 機種すべて変動なし（Layer 1 が天井）  judgement 誤り (本質✗)  Gemini 系で 2 件改善、Sonnet で 1 件退化、Opus/Gemma3 横ばい  reasoning の質  全機種で短くなった（thinking が消えた分）  ユーザ体験  全機種で改善（特に Flash / Pro / Sonnet） 正答数だけ見ると不動、judgement の倒れ方を見ると微妙にトレードオフ、というのが実態です。「精度ゼロ犠牲で 70〜85% 速くなった」と書くのは大筋では正しいですが、Sonnet TC-002 のような小さな退化が出る可能性はあります。学んだことこのシリーズ全体を通して、最初は「Layer 1 を Layer 2 にどう渡すか」が論点でしたが、今回の高速化編で 「Layer 1 と Layer 2 の責任分界をどこに引くか」 という設計の話に降りてきた気がします。Layer 1 で根拠を完全に渡しているなら、Layer 2 の thinking は自動的に冗長になる。 思考のための情報を後段に追加で取りに行く余地がないため、思考時間は整形を遅らせるだけです。デフォルトの thinking_budget（Gemini）/ adaptive thinking（Anthropic）はクライアント側で明示オフにする値。 プロバイダ側のデフォルトは「LLM 単体で論理推論させる」ユースケースを想定しているので、ハイブリッドではオプトアウトする側になる、というのは設計の知見として持っておきたい点です。本番経路で実測しないとレイテンシ支配項は分からない。 今回 Layer 1 が 3.3ms（全体の 0.1%）と分かったのは、scripts/profile_hybrid.py で本番経路を踏ませてからでした。コンポーネントを単独で測ると見落とします。思考時間と判定精度は単調じゃない。 TC-003 で Gemini Flash/Pro が「思考オフのほうが正しい」結論に倒れたのは、思考が長くなるほど LLM が独自の三段論法を組み立てて結論を歪める例でした。ハイブリッド構成では、Layer 2 に「考えるな、整形しろ」と要求するほうが安全側です。同じ撤廃で全機種が同じ方向に動くわけではない。 Sonnet が TC-002 で退化したのは、Layer 1 が穴を開けたケースではなく、むしろ Layer 1 がちゃんと条文を渡せているのに最終フィールドに焼き込む段で揺らいだ事故です。adaptive thinking 撤廃の影響度が Sonnet と Opus で割れたのも、ベンダー内のモデル差として面白い観測でした。留保数字を扱う以上、いくつか正直に書いておきたい留保があります。「Flash 105× 高速化」は best-case の単発値です。 PR コミットメッセージの数字は短い 3 クエリでの計測。実運用相当の 7 ケース通しでは平均 21 秒（最大 47 秒）まで残っています。ベンチマーク上では 77% 削減、最悪ケースで 84%、というのが実態に近いです。Gemma3 の 52% 削減は、thinking 撤廃の効果ではない可能性が高いです。 Ollama サーバ側の負荷変動の混入か、ベンチ実行のばらつきだと思っています。ここは差分として参考程度に。7 ケースでは精度差を見切れないのは前回どおりです。 Sonnet の TC-002 退化が「thinking 撤廃の影響」か「同条件で 2 回回したら 1 回は外す揺らぎ」かは、本来は数十回繰り返さないと分離できません。thinking を切るのが正解なのはハイブリッド構成だから、です。 LLM 単体で法令判定をさせる経路（--benchmark）では Pre-fix の挙動のままにしてあります。Layer 1 がないなら、Layer 2 は自分で考えるしかありません。結論決定論で根拠を取り切れている範囲では、LLM の推論は不要。ハイブリッド構成における Layer 2 の thinking は冗長で、明示的に切るほうがレイテンシが 70〜85% 縮み、正答数は動かず、judgement の倒れ方は機種により小さく改善 / 退化する。「2008 年の卒論コードが 2026 年の LLM の尻拭いをする」というタイトル一発ネタで始まったシリーズも、ここまで来ると「卒論で根拠を取り切っているなら LLM は考えなくていい」という、もうちょっと真面目な設計指針に落ちました。次の課題はやはり 2 つ残っています。1 つは Layer 1 の TF-IDF を意味埋め込みに置き換えて TC-003「ながらスマホ」の取りこぼしを救うこと。もう 1 つはテストケースを 7 → 数十〜100 のオーダーに拡張して、今回の Sonnet TC-002 退化が再現するかを確かめること。後者はそのまま回帰テストにもなりそうです。読んでいただきありがとうございました。シリーズで一番地味で実装寄りの回でしたが、ハイブリッド構成を運用に乗せる前に踏みたい一段ではあったので、書き残しておきます。決定論で塗れる範囲をどこまで広げられるか、その外側にだけ LLM を置く — というのが、5 編書いて最後に残った設計指針でした。そして「完結編」と銘打った直後にこうして続編を書くことになったわけですが、ベンチマークは公開して晒すと自分で見落としていた穴に気づける、というのが今回の副次的な学びでした。次こそ完結編、とはもう書かないことにします。↓ソースコードはこちらgithub.com]]></content:encoded>
            <author>Shu Kobuchi</author>
        </item>
        <item>
            <title><![CDATA[Google Cloud の IAP + WIF + IdP (Keycloak) でログイン実装]]></title>
            <link>https://qiita.com/gensan0223/items/253be34266202111a453</link>
            <guid isPermaLink="false">https://qiita.com/gensan0223/items/253be34266202111a453</guid>
            <pubDate>Tue, 12 May 2026 23:44:52 GMT</pubDate>
            <content:encoded><![CDATA[はじめにGoogle Cloud環境でのログイン機能を実装するとき、何を使っていますか？「すでに社内で Okta や Keycloak、Microsoft Entra ID などの IdP を運用しているのに、Google Cloud 用に別のユーザー管理をしたくない...]]></content:encoded>
            <author>Gemma Naoki</author>
        </item>
        <item>
            <title><![CDATA[ドメイン規約を lint で表現する — `Order` に `is_paid + payment_id` を共存させない]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/12/161200</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/12/161200</guid>
            <pubDate>Tue, 12 May 2026 07:12:00 GMT</pubDate>
            <content:encoded><![CDATA[はじめに長年、動いた DB を覗くと、たいていこういうレコードが眠っています。{ "order_id": 12345, "is_paid": true, "payment_id": null }is_paid = true なのに payment_id は null。 ドメイン上、 同時に成立してはいけない組み合わせ が静かに残り、数ヶ月後の返金処理で例外を出します。今後 「型で illegal state を表現不能にする」みたいな発表をします 。 そしてその前の記事で書いた 「rowan で自家製 lint を書く」 は、 cargo check に乗らない規約をコードで強制する話でした。 この 2 つを 組み合わせて実プロジェクトに入れるとどうなるか が今回のテーマです。型は壁、Rustでもバグを直すな、表現できなくせよ by nwiizo | トーク | 関数型まつり2026 #fp_matsuri - fortee.jpsyu-m-5151.hatenablog.com順番:ドメインを enum で型の壁にする (= 新規コードへのガード)既存コードベースに残る古いパターンを rowan の lint で網を張る (= 後戻りのガード)lint を build.rs / xtask で常時走らせて CI で機械化する古いコードを少しずつ enum 化しながら lint を緩めていく題材は Order ドメイン。 「未払い / 支払い済み」 を is_paid: bool + payment_id: Option<_> で表現していたコードベースに、型と lint の 2 段の壁を入れていきます。第一の壁 — 型で illegal state を消す問題のある struct はこうでした。pub struct Order {    pub id: u64,    pub is_paid: bool,    pub payment_id: Option<u64>,}このとき作れる組み合わせは 4 通り。 「未払い・決済 ID なし」 と 「支払い済み・決済 ID あり」 の正常 2 通りに加えて、 「未払いだが決済 ID がある」 「支払い済みだが決済 ID がない」 の 不正 2 通りも書けてしまう 。 4 通り全部にコメント書いてレビューで潰す、では運用できません。正常な状態だけを enum で並べ直します。#[derive(Debug)]pub struct OrderId(u64);#[derive(Debug)]pub struct PaymentId(u64);#[derive(Debug)]#[non_exhaustive]pub enum PaymentStatus {    Unpaid,    Paid {        payment_id: PaymentId,        paid_at: chrono::DateTime<chrono::Utc>,    },}pub struct Order {    pub id: OrderId,    pub status: PaymentStatus,}これで 「未払いだが決済 ID あり」 は 書こうとしても書けません 。 Unpaid には payment_id フィールドがそもそも存在しない。 match で書けば、 将来 Refunded を追加した瞬間に 未対応の関数が全部コンパイルエラーになり、 修正漏れが起こせなくなります。ここまでが前の記事 「illegal state を表現不可能にする」 の振り返り。 ここから先が今回の本題です。なぜ型だけでは止まらないのか問題は 既存コードベース です。 5 年動いてきたサービスのコードは、 こうなっています。ハンドラは Order { id, is_paid, payment_id } を受け取って serde::Deserialize でビルドするrepository 層は DB の is_paid BOOLEAN, payment_id BIGINT NULL をそのまま Option にする業務コードのあちこちで if order.is_paid && order.payment_id.is_some() { ... } のガードが書かれているここに 「Order を enum にしましょう」 を一気に投げると、 ハンドラ・リポジトリ・ガード・テストが 全ファイル巻き込まれて 数日仕事になります。 そして、 その間も誰かが古い形で pub struct Order { is_paid: bool, payment_id: Option<u64> } を別ドメインに書き足す可能性が消えません。新規の侵入を コンパイラで止める のが lint の役目です。 cargo check は 「型として正しいか」 しか見てくれません。 「同じ struct に is_paid: bool と payment_id: Option を共存させたら警告」 のような規約は、 自分で書く必要があります。第二の壁 — rowan で bool-option-pair lint を書くrowan は rust-analyzer 内部の構文木ライブラリで、 ra_ap_syntax を経由すれば Rust grammar をそのまま借りられます。 構文木を歩いて、規約違反を見つけたら診断を吐くだけ。最小実装はこうなります。use ra_ap_syntax::ast::{self, AstNode, HasName, HasVisibility};pub struct BoolOptionPair;#[derive(Debug)]pub struct Diagnostic {    pub rule: &'static str,    pub message: String,    pub line: u32,}impl BoolOptionPair {    pub fn check(&self, file: &ast::SourceFile, source: &str) -> Vec<Diagnostic> {        let mut diags = Vec::new();        for node in file.syntax().descendants() {            let Some(strukt) = ast::Struct::cast(node) else { continue };            let Some(ast::FieldList::RecordFieldList(fields)) = strukt.field_list() else {                continue;            };            // public な bool フィールドと Option フィールドを集める            let mut bool_flags: Vec<(String, ra_ap_syntax::TextRange)> = Vec::new();            let mut options: Vec<String> = Vec::new();            for field in fields.fields() {                if field.visibility().is_none() { continue }                let Some(name) = field.name() else { continue };                let Some(ty)   = field.ty()   else { continue };                let nm = name.text().to_string();                let tt = ty.syntax().text().to_string();                if tt == "bool" {                    bool_flags.push((nm, field.syntax().text_range()));                } else if tt.starts_with("Option<") {                    options.push(nm);                }            }            // ドメイン規約: `is_*` / `has_*` / `was_*` のフラグと            //               `_id` / `_at` で終わる Option が同じ struct にいるとき警告            let suspicious_option = options.iter().any(|n| {                n.ends_with("_id") || n.ends_with("_at") || n.ends_with("_by")            });            for (name, range) in &bool_flags {                let prefixed = name.starts_with("is_")                    || name.starts_with("has_")                    || name.starts_with("was_");                if prefixed && suspicious_option {                    let line = line_of(source, range.start());                    diags.push(Diagnostic {                        rule: "bool-option-pair",                        message: format!(                            "`{name}: bool` と隣接する `Option<_>` フィールドは相関状態を許す。\                             enum で variant に揃える"                        ),                        line,                    });                }            }        }        diags    }}fn line_of(source: &str, offset: ra_ap_syntax::TextSize) -> u32 {    let target: usize = offset.into();    source[..target.min(source.len())].matches('\n').count() as u32 + 1}100 行弱。 ヒューリスティックは荒いですが、 これでさっきの Order struct はちゃんと引っかかります。let src = r#"    pub struct Order {        pub id: u64,        pub is_paid: bool,        pub payment_id: Option<u64>,    }"#;let parse = ra_ap_syntax::SourceFile::parse(src, ra_ap_syntax::Edition::Edition2024);for d in BoolOptionPair.check(&parse.tree(), src) {    println!("L{}: [{}] {}", d.line, d.rule, d.message);}// L4: [bool-option-pair] `is_paid: bool` と隣接する `Option<_>` フィールドは相関状態を許す。enum で variant に揃えるcargo check も clippy もこの違反を見ません。 自分で書いてはじめて、 ドメイン語彙が壁になります。ヒューリスティックを育てる最初の lint は荒くて構いません。 偽陽性 (false positive) が出たら、 そのコードを見て妥当な反論 を書き、 ヒューリスティックを絞ります。 たとえば:is_premium: bool + subscription_url: Option<Url> のような 完全独立な 2 値 は、 stem を要求して回避できる (is_paid ↔ payment_id のように 語幹が部分一致 する場合のみ警告)#[serde(default)] の DTO は外部入力を一旦受けるための型なので、 内側に拡散しない限り許可する → struct に #[derive(Deserialize)] がついていたらスキップする「正しい lint」 は最初から書けません。 ドメインの理解が深まるごとに、 ヒューリスティックを足したり緩めたりする のが運用です。 lint も育てる対象です。lint と test の棲み分け「ドメイン規約を lint で書ける」 と言うと、 ほぼ必ず 「テストでよくないか？」 と問われます。 答えは どちらも要る、 ただし担当が違う です。 自分の samples/rust-types-as-walls で cargo test --tests を回すと、 9 suites で 22 件が並走します。 内訳を見ると、 道具を揃えた意図がはっきりします。 守りたいこと  道具  具体例  型として正しいか (網羅性、所有権、取り違え)  コンパイラ  match PaymentStatus の variant 網羅、UserId と OrderId の混合禁止  「同じ struct に bool + Option を共存させない」 ような構造規約  rowan lint  bool-option-pair, raw-id-field, non-exhaustive-pub-error  「このコードはコンパイルエラーであるべき」 という API 契約  trybuild UI test  non_exhaustive_match_fail, sealed_trait_external_impl_fail (tests/fixtures/)  パース成功 → 値の不変条件を満たす  property test (proptest)  Password::new が返す値が長さ・文字種を必ず満たす  ドメインの振る舞い  integration test  「未払い注文を ship しようとしたら 4xx」 線の引き方は明快です。 構造的・静的なルールはコンパイラ + lint、 値・振る舞いのルールは property + integration test 。 is_paid: bool + payment_id: Option<_> を弾くのは前者なので lint、 「Order::pay() を 2 回呼んだら error」 は後者なのでテスト。 互いに領域が重ならず、 置換できません。4 段で何が止まるかを揃えるCI で 4 段全部回すと、 PR が どの壁を破ろうとしているか をレビュー前に分けて見られます。 ステップ  通る条件  落ちたとき直す場所  cargo build  型として正しい  プロダクトコードの型  cargo xtask lint  ドメイン規約に違反しない  構造を直すか、 lint を緩める判断  cargo test --tests (trybuild)  「コンパイルエラーになるべき」 が壊れていない  API 契約のレグレッション  cargo test --tests (proptest / integration)  値・振る舞いが期待通り  実装のバグ samples/rust-types-as-walls の test 構成は、 この 4 段の 3 段目と 4 段目 を担当しています。 tests/fixtures/non_exhaustive_match_fail/ は 「#[non_exhaustive] enum を _ => 無しで match するとコンパイルエラーになる」 を契約として固定し、 tests/password_props.rs は 「Password::new が返す全候補が不変条件を満たす」 を property で固定する。 lint (2 段目) を加えると、 「そもそも password: String のような raw 型を field に晒さない」 という 構造の話 がここに刺さります。lint に寄せすぎてはいけないもの逆方向の境界線も同じくらい重要です。 lint で書けてしまうが、 書くべきでない ものがあります。値の中身に依存するチェック (例: 「due_at は created_at より後」)。 値が走らないと判定できないので property test の領域。 lint で正規表現を書き始めたら警報。複雑なドメイン式 (例: 税計算、 割引)。 ユニットテストで十分。 lint に持ち込むと、 荒い AST マッチングで偽陽性が増えます。言語仕様レベルの一般則 (例: 「Result を返す関数で ? を使うべき」)。 clippy が既に持っているなら、 自家製で書き直さない。「コンパイラが見逃す構造的な規約」 だけが、 自家製 lint の領域です。 ここを越えるとテストや clippy と仕事が被って、 メンテナンスコストだけが増えます。偽陽性が出たときの逃がし方運用していると、 ドメイン的に正当な struct がたまたま lint に引っかかる事故が必ず起きます。 そのときの逃がし方を最初から決めておきます。 自家製 lint は 2 段階で逃がせるように作っておくのが、 実運用に耐えるための最小要件 です。1. コメントで局所的に逃がす — 違反箇所だけ・直前のコメント行で抑制。// rbp-lint-allow: bool-option-pair (reason: 旧スキーマ互換のため正しく相関しない)pub struct LegacyOrder {    pub is_paid: bool,           // ← この struct 内では bool-option-pair を抑制    pub payment_id: Option<u64>,}let raw = std::fs::read_to_string("/etc/passwd").unwrap(); // rbp-lint-allow: no-unwrap行末・直前数行・ファイル先頭の // コメントを lint 側でスキャンします。 同一行末尾は その行の違反だけ、 直前の連続コメント行は 次の違反 1 件、 ファイル先頭 (//! 含む) は そのファイル全体 に効きます。2. プロジェクト単位は .rbp-lint.toml で逃がす — ルールごとの severity 上書き、 パスごとの除外。# .rbp-lint.toml (リポジトリルートに置く)[rules]no-unwrap          = "error"      # 既定どおり強制bool-option-pair   = "warning"    # 段階導入の途中は warning に下げるdebug-print        = "off"        # CLI バイナリでは println! を許す[paths]exclude = ["**/examples/**", "vendor/**", "**/*_generated.rs"]off にすると lint そのものを実行しない。 既存違反が大量に残るプロジェクトに lint を入れるとき、 まず全部 warning で着地して、 1 つずつ消えたら error に上げる、 という運用が成り立ちます。ここで揃えたいのが、 sample の test ファイル冒頭で見える Rust 1.81+ 流儀です。#![allow(    clippy::expect_used,    clippy::panic,    clippy::unwrap_used,    reason = "tests keep assertions and fixture setup direct")]reason = "..." を必須にする慣習を、 自家製 lint の suppression にも持ち込みます。 // rbp-lint-allow: rule (reason: ...) という書式を コードレビューで強制 すれば、 「許す」 ことは許すが 理由なしの許可は許さない 状態になります。 「ここは bool-option-pair を抑制してるが、 なぜ?」 がコードに残る。 lint が厳しすぎても緩すぎても運用が壊れる中で、 この第三の道だけが続きます。まだ実装していない逃がし方: 「baseline」 — git diff main で新規追加された違反だけ報告する機能です。 巨大コードベースに lint を一気に入れるとき、 既存違反を「いまある分は放置、 新規だけ警告」 にできれば段階導入が一気に楽になります。 --baseline=main.json でスナップショットを許可リストとして渡す設計が定石ですが、 これは別記事で。 当面は .rbp-lint.toml の warning 段階と、 違反箇所への suppression コメントで代替できます。CI に組み込む — 違反コードを commit させないlint を CLI で書いただけでは、 走らせ忘れた瞬間に違反が混ざります。 自家製 lint を cargo build か cargo xtask lint で常時走らせる 仕組みを必ずセットで作ります。xtask パターン (推奨)workspace に xtask/ を切って、 cargo xtask lint で起動します。 開発中の cargo check を遅くしないので、 普段使いに向いています。# .cargo/config.toml[alias]xtask = "run --package xtask --release --"// xtask/src/main.rsuse std::process::ExitCode;fn main() -> ExitCode {    let cmd = std::env::args().nth(1).unwrap_or_default();    if cmd != "lint" {        eprintln!("usage: cargo xtask lint");        return ExitCode::from(2);    }    let mut errors = 0;    for entry in walkdir::WalkDir::new("crates") {        let entry = entry.expect("walkdir");        if entry.path().extension().is_some_and(|e| e == "rs") {            for d in my_lint::lint_file(entry.path()).expect("lint") {                println!("{}:{}: [{}] {}", d.file.display(), d.line, d.rule, d.message);                if matches!(d.severity, my_lint::Severity::Error) {                    errors += 1;                }            }        }    }    if errors > 0 { ExitCode::from(1) } else { ExitCode::SUCCESS }}build.rs で cargo build 時にも走らせる「絶対 commit させない」 を強制したいなら、 build.rs で同じ lint を呼んで panic!() で止めます。// build.rsfn main() {    println!("cargo:rerun-if-changed=src");    let mut errors = 0;    for e in walkdir::WalkDir::new("src") {        let e = e.expect("walkdir");        if e.path().extension().is_some_and(|x| x == "rs") {            println!("cargo:rerun-if-changed={}", e.path().display());            for d in my_lint::lint_file(e.path()).expect("lint") {                println!("cargo:warning={}:{}: [{}] {}",                    d.file.display(), d.line, d.rule, d.message);                if matches!(d.severity, my_lint::Severity::Error) { errors += 1; }            }        }    }    if errors > 0 {        panic!("ドメイン lint: {errors} 件の error を解消するまでビルドできません");    }}build.rs は 強い ですが、 cargo check が遅くなる副作用があるので、 開発体験を見ながら判断します。 個人的には xtask + CI step で十分なケースが多く、 build.rs は致命的なドメイン規約だけに絞ることが多いです。CI stepGitHub Actions ならこれだけ。- name: domain lint  run: cargo xtask lintPR に lint 結果が必ず出るので、 レビュー前に弾けます。段階導入のロードマップbool-option-pair を一気に error にすると、 既存違反が大量に出てビルドが通らない状況になりがちです。 段階を踏みます。 すべて .rbp-lint.toml のルール severity を切り替えるだけで実現できます 。 フェーズ  .rbp-lint.toml の設定  目標  1. 着地  bool-option-pair = "note"  既存違反を可視化する。 ビルドは止めない  2. 新規ガード  bool-option-pair = "warning" + 既存違反箇所に // rbp-lint-allow: を貼る  新規違反だけ警告。 既存はコメントで明示的に「保留」と記録  3. 全違反禁止  bool-option-pair = "error" + suppression コメントは reason 必須  違反 = ビルド不可。 suppression は理由付きで残す phase 2 の発想は 「--baseline=main で git diff から新規違反だけ抜く」 と同じことを、 suppression コメントを許可リストとして使って静的に表現する ことで実現します。 既存違反 1 件ごとに // rbp-lint-allow: bool-option-pair (reason: 旧スキーマ互換) を貼る作業は、 コードレビュー上 「ここは技術的負債である」 が永続化される副産物が大きい。 純粋な baseline ファイル方式と違って、 負債の理由がコードに残ります 。phase 3 に到達したら、 bool-option-pair は 書けないドメイン規約 になります。 そこで初めて、 古いコードの enum 化を 1 ファイルずつ進められます。実運用上の落とし穴 — 数値で測る理論だけで終わらせないために、 手元の tools/rbp-lint (今回紹介した 21 ルール) を実コードに当てた数字を出します。 対象  files  実時間 (release ビルド)  診断数  コメント  tools/rbp-lint/src (lint 自身)  28  0.38s  15  うち 9 件は lint 自身のパターン定義文字列 が hardcoded-secret に引っかかった偽陽性  samples/rust-types-as-walls  48  1.09s  135  examples/ の println! で debug-print が大量発火  samples/idiomatic-rust-2024  17  <0.01s  44  キャッシュ後再実行 学べることは 3 つ。性能は CI 用途には十分。 cold で 1 ファイル 13–22ms。 千ファイルでも 20 秒程度です。 ただし rust-analyzer のリアルタイム保存ループ (300ms 想定) に混ぜるには重いので、 build.rs で全部走らせるのは避ける のが無難。 致命級だけ build.rs、 通常は xtask + CI、 が現実解です。lint 自身がいちばん最初の偽陽性源。 hardcoded_secret.rs には "sk-", "AKIA" のような検出対象パターンを文字列リテラルとして書く必要がありますが、 これが 自分の lint で error 級違反として検出される という鏡像が起きました。 解決は 1 行: その lint ファイルの先頭に // rbp-lint-allow: hardcoded-secret (reason: パターン定義そのもの) を入れるだけ。 他の偽陽性 (CLI の println!, 学習用 example の panic! など) も同じく .rbp-lint.toml の [paths] exclude か [rules] debug-print = "off" で潰せます。 suppression と config が無いと lint は実運用で死にます。ドメインに合わせて lint 数を絞ること。 21 ルール全部 on で example 48 ファイルから 135 件の診断は、 シグナル/ノイズ比が悪い。 プロダクト本体ではなく example だから出てるノイズです。 自分のコードベースで意味のあるルールを 5–10 個に絞り、 残りは off、 が運用の落とし所。 「全部入れる」 は誰も喜びません。ra_ap_syntax のバージョン揺れブログの例は ra_ap_syntax = "0.0.331" で書いてあります。 rust-analyzer に追従する関係で minor で API が変わる ので、 lint プロジェクトでは:Cargo.lock を 必ずコミット する (binary crate 扱い)上げるときはまず cargo test で AST cast の壊れを検出CI に「依存固定検査」 (cargo update --dry-run で意図しない上昇を弾く) を入れるこれを忘れると、 ある日 PR が落ちたとき 「自分のコードは何も変えてないのに lint が通らない」 という事故になります。cargo:warning= の落とし穴build.rs で cargo:warning=... だけ書くと 黄色く出るだけで cargo build は止まりません。 「絶対 commit させない」 を実現したいなら必ず panic!() を呼ぶ必要があります。 ブログの build.rs サンプルにも入れていますが、 ここを忘れて運用 1 ヶ月後に発覚するパターンが定番なので、 統合した直後に わざと違反を入れて build が落ちることをテスト で確認するところまでセットです。 phase 1 と phase 2 (clean) の双方向検証を、 cargo build --release を CI で回す形で残しておくと安全です。ドメイン語彙を lint に育てるbool-option-pair を入れて運用していると、 似たパターンの違反が見えてきます。pub *_id: String / pub *_id: u64 の raw identifier (UserId / OrderId の取り違え予備軍)pub status: String の stringly typed status (enum で潰すべき)pub enum *Error で #[non_exhaustive] がない (SemVer 互換が壊れる)これらを 1 つずつ lint にすると、 コードレビューの目視チェックリストがコードに昇格 します。 レビューで毎回同じ指摘を書いている、と思ったら lint 化のタイミングです。ドメインに長く居るほど、 「このパターンが出たら危ない」 という嗅覚が増えます。 その嗅覚を コードに固定する のが、 自家製 lint の最大の価値です。 メンバー交代でも腐らない、 AI が書いた PR でも止まる、 type system の続きにある防衛線として動きます。手元の tools/rbp-lint には bool-option-pair / raw-id-field / status-string-field / non-exhaustive-pub-error の 4 つを実装してあります。 全部この記事と同じパターンで書けます。まとめ型で illegal state を消した上に、 lint で 「規約違反」 もコンパイルエラーにするrowan (ra_ap_syntax) で書けば、 cargo check に乗らない規約も静的検出できるxtask + CI step で常時走らせる。 致命なら build.rs で cargo build も止めるsuppression コメント + .rbp-lint.toml を最初から入れる。 偽陽性の逃げ場が無いと lint は実運用で死ぬいきなり error にせず、 note → warning + suppression コメント → error で段階導入するレビューで毎回出る指摘は lint に昇格させる。 ドメイン語彙が そのまま壁になる型と lint は、 同じ 「壁を築く」 行為の表裏です。 型で書けないものを止め、 lint で書けるが書いてはいけないものを止める。 両方を持つと、 ドメインモデルは AI が書いた PR にも壊されません。 そして 「許す」 ことを reason: ... 付きで残せる第三の道を持っておけば、 厳しすぎず緩すぎずの実運用に着地できます。関連リポジトリ: rust-analyzer/rowan / ra_ap_syntax"Make illegal states unrepresentable" (Yaron Minsky) / Scott Wlaschin "Designing with Types"]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[`rowan` で自家製 lint を書く — rust-analyzer の心臓部を流用する]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/11/122425</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/11/122425</guid>
            <pubDate>Mon, 11 May 2026 03:24:25 GMT</pubDate>
            <content:encoded><![CDATA[はじめにエディタで .unwrap() を書いた瞬間、 clippy が黄色い波線を引きます。match で variant が漏れた瞬間、コンパイラが赤い波線を引きます。これは魔法ではなく、 rust-analyzer が rowan で構文木を作り、その上をルールが歩いている というしくみです。github.com/rust-analyzer/rowan は rust-analyzer の心臓部にある、ロスレスな構文木 (concrete syntax tree, CST) ライブラリです。これまでの記事で書いた「.unwrap() を避ける」「pub enum *Error には #[non_exhaustive] を付ける」のような ドメインの規約 は、 cargo check だけでは強制できません。 rowan を直接使えば、自分のコードベース固有のルールも CI で機械的に弾けるようになります。この記事は rowan の何が嬉しいかを整理し、 ra_ap_syntax (rust-analyzer の Rust grammar を rowan に乗せたもの) を使って 既存シリーズで紹介した規約 2 つを自家製 lint にする までを書きます。なぜ rowan か — syn と何が違うかRust で構文を扱うとき、第一候補は syn です。 proc-macro でおなじみの、AST 生成ライブラリ。syn で十分な仕事も多くあります。 では rowan は何が違うのか。 観点  syn  rowan  木の種類  抽象構文木 (AST)  具象構文木 (CST, ロスレス)  空白・コメント  落ちる  保持される  構文エラー  取り出した時点で fail  エラー含みで木を返す (resilient parsing)  木の表現  所有権ベース  不変共有 (Green/Red tree)  主な用途  proc-macro / コード生成  IDE / lint / フォーマッタ 「lint を書きたい」場面では、ロスレスと resilient が効きます。 // SAFETY: コメントの直前にあるか、を判定したい lint は コメントが残る木 でないと書けない。型注釈が途中で書きかけのファイルでも補完を出したいなら、 構文エラーがあっても部分木で操作できる ことが必要。 rowan はこの 2 つを正面から扱います。syn は「マクロが受け取った正しい構文を、所有権付きで操作する」のが得意。 rowan は「人間が書きかけている構文を、空白を含めて見ながら警告を出す」のが得意です。Green tree / Red tree の二層構造rowan の特徴的な内部設計が 2 層 です。 Green tree は不変・スレッド共有可能な木で、 Arc で参照カウントされた node がノード間で再利用されます。 Red tree (SyntaxNode) は Green tree の上に 親ポインタとオフセット情報 を載せた viewer で、こちらが lint コードから触る側です。何が嬉しいか:ファイル中の同じ識別子 (Foo) は Green node を共有できる → メモリが食わない編集が起きたとき、変わらない部分の Green node はそのまま再利用 → 差分パースが速いRed node は node.parent() / node.children() / node.text_range() といった IDE で必要な操作を全部持っているユーザーとしては「Red 側の SyntaxNode を歩く」だけ理解すればよくて、Green 側は黒箱で構いません。ra_ap_syntax で Rust をパースする最小コードrowan 自体は 言語に依存しないフレームワーク です。Rust を rowan で扱いたいときは、 rust-analyzer がパッケージとして公開している ra_ap_syntax を使います。 rust-analyzer の grammar をそのまま借りられるので、自分でパーサーを書く必要はありません。# Cargo.toml[dependencies]ra_ap_syntax = "0.0.331"rowan = "0.16"最小コード。use ra_ap_syntax::{ast::AstNode, Edition, SourceFile};fn main() {    let src = r#"        fn run() -> Result<(), std::io::Error> {            let s = std::fs::read_to_string("a.txt").unwrap();            Ok(())        }    "#;    let parse = SourceFile::parse(src, Edition::Edition2021);    // 構文エラーがあってもここで panic しない    for err in parse.errors() {        eprintln!("syntax error: {err}");    }    let tree = parse.tree();    for node in tree.syntax().descendants() {        if let Some(method) = ra_ap_syntax::ast::MethodCallExpr::cast(node) {            if let Some(name) = method.name_ref() {                if name.text() == "unwrap" {                    let range = method.syntax().text_range();                    println!("unwrap() at {:?}", range);                }            }        }    }}SourceFile::parse は Parse<SourceFile> を返し、 .tree() で SourceFile AST を取れます。 SourceFile::syntax() で SyntaxNode (Red tree) に戻せて、 descendants() で全 node を歩けます。 lint を書くときの基本は descendants でループ → AST cast → 述語チェック → range を診断に乗せる の 4 ステップです。例 1: .unwrap() を検出する lint「unwrap() を書きそうになったときの選択肢8つ」の記事で示した規約「プロダクションコードで .unwrap() を書かない」を機械化します。syu-m-5151.hatenablog.comuse ra_ap_syntax::ast::{self, AstNode};pub struct UnwrapLint;#[derive(Debug)]pub struct Diagnostic {    pub message: String,    pub start: usize,    pub end: usize,}impl UnwrapLint {    pub fn check(&self, file: &ast::SourceFile) -> Vec<Diagnostic> {        let mut diags = Vec::new();        for node in file.syntax().descendants() {            let Some(method) = ast::MethodCallExpr::cast(node) else {                continue;            };            let Some(name) = method.name_ref() else { continue };            if name.text() != "unwrap" {                continue;            }            // テストコード配下なら飛ばす（後述の補助関数）            if is_in_test_context(method.syntax()) {                continue;            }            let range = method.syntax().text_range();            diags.push(Diagnostic {                message: "`.unwrap()` is forbidden in production code".into(),                start: range.start().into(),                end: range.end().into(),            });        }        diags    }}ポイントは is_in_test_context。#[cfg(test)] mod tests { ... } や #[test] fn ... の中なら見逃したい。 SyntaxNode::ancestors() を使えばたどれます。use ra_ap_syntax::ast::{HasAttrs, HasName};fn is_in_test_context(node: &ra_ap_syntax::SyntaxNode) -> bool {    for ancestor in node.ancestors() {        if let Some(func) = ast::Fn::cast(ancestor.clone()) {            if func.attrs().any(|a| {                a.path()                    .map(|p| p.syntax().text().to_string() == "test")                    .unwrap_or(false)            }) {                return true;            }        }        if let Some(module) = ast::Module::cast(ancestor.clone()) {            if module                .name()                .map(|n| n.text() == "tests" || n.text() == "test")                .unwrap_or(false)            {                return true;            }            if module.attrs().any(|a| a.syntax().text().to_string().contains("cfg(test)")) {                return true;            }        }    }    false}これで cargo test で隔離された .unwrap() は流して、本番コードだけ弾けます。 clippy::unwrap_used よりも 「test 配下を除外」のヒューリスティックが自分で調整できる ところが自家製 lint の利点です。例 2: 公開 enum で #[non_exhaustive] 漏れを検出「thiserror と #[non_exhaustive] で SemVer に強いエラー型を作る」の記事で扱った「pub enum *Error には #[non_exhaustive] を付ける」も、 rowan で書けます。syu-m-5151.hatenablog.comuse ra_ap_syntax::ast::{self, AstNode, HasAttrs, HasName, HasVisibility};pub fn check_non_exhaustive_error(file: &ast::SourceFile) -> Vec<Diagnostic> {    let mut diags = Vec::new();    for node in file.syntax().descendants() {        let Some(en) = ast::Enum::cast(node) else { continue };        if en.visibility().is_none() {            continue; // 非公開はスキップ        }        let Some(name) = en.name() else { continue };        let name_text = name.text().to_string();        if !name_text.ends_with("Error") {            continue; // ヒューリスティックで Error 系のみ        }        let already_marked = en.attrs().any(|a| {            a.path()                .map(|p| p.syntax().text().to_string() == "non_exhaustive")                .unwrap_or(false)        });        if already_marked {            continue;        }        let range = en.syntax().text_range();        diags.push(Diagnostic {            message: format!(                "`pub enum {name_text}` should be `#[non_exhaustive]` to keep SemVer flexibility"            ),            start: range.start().into(),            end: range.end().into(),        });    }    diags}clippy にはこのチェックは入っていません。チームのドメイン規約は、 rowan で書いて CI に乗せる のが現実解です。 lint がコードベースに固有なら、 clippy ではなく自家製で持つほうが運用が楽になります。動かす — cargo run -- src/lib.rsCLI に組むのも数行です。fn main() {    let path = std::env::args().nth(1).expect("usage: lint <file>");    let src = std::fs::read_to_string(&path).expect("read");    let parse = ra_ap_syntax::SourceFile::parse(&src, ra_ap_syntax::Edition::Edition2021);    let tree = parse.tree();    let mut diags = UnwrapLint.check(&tree);    diags.extend(check_non_exhaustive_error(&tree));    for d in &diags {        println!("{}-{}: {}", d.start, d.end, d.message);    }    std::process::exit(if diags.is_empty() { 0 } else { 1 });}これで cargo run -- src/lib.rs するだけ。 walkdir でディレクトリを再帰すれば、 prj 全体の検査ツールになります。CI に組み込む — build.rs / xtask / pre-commitCLI で手動実行できても、 誰かが忘れた瞬間にコードベースに違反が混ざります。 lint を機械的に走らせる仕組みは別途用意します。 候補は 4 つ。1. build.rs で cargo build 時に走らせる利用側 crate の build.rs から自家製 lint をライブラリとして呼びます。 違反があれば panic! で cargo build を止めます。# 利用側 Cargo.toml[build-dependencies]my-lint = { path = "../tools/my-lint" }walkdir = "2"// build.rsuse std::path::Path;fn main() {    let src = Path::new("src");    println!("cargo:rerun-if-changed=src");    let mut errors = 0;    for entry in walkdir::WalkDir::new(src) {        let entry = entry.expect("walkdir");        if entry.path().extension().is_some_and(|e| e == "rs") {            println!("cargo:rerun-if-changed={}", entry.path().display());            for d in my_lint::lint_file(entry.path()).expect("lint") {                println!(                    "cargo:warning={}:{}: [{}] {}",                    d.file.display(), d.line, d.rule, d.message                );                if matches!(d.severity, my_lint::Severity::Error) {                    errors += 1;                }            }        }    }    if errors > 0 {        panic!("custom lint: {errors} error-level diagnostic(s); fix them to build");    }}cargo:warning=... は cargo が黄色い警告として表示してくれる行。 panic! を呼ばないと cargo は止まらないので、致命と非致命の境界は自分で決めます。向いている: 「絶対に違反したまま動かしたくない」 lint。 cargo build / check / test 全てで強制される。向いていない: 開発中に何度も cargo check を回すワークフロー。 build script の incremental は rerun-if-changed でしか効かないので、 src/ を再帰させるとファイル追加のたびに走ります。 rust-analyzer のリアルタイム保存ループに lint まで含めると、エディタ側が遅くなる可能性も。2. xtask で cargo xtask lint を作るRust 慣習の xtask パターンは、 workspace に xtask/ バイナリを切って cargo xtask <command> で開発系のタスクを走らせる方式です。 build.rs と違い cargo build には乗らないので、開発の重さに影響しません。# workspace ルートの .cargo/config.toml[alias]xtask = "run --package xtask --"// xtask/src/main.rsfn main() {    let cmd = std::env::args().nth(1).unwrap_or_default();    match cmd.as_str() {        "lint" => run_lint(),        _ => eprintln!("usage: cargo xtask lint"),    }}fn run_lint() {    // ここで my_lint::lint_file を全 .rs に対して呼ぶ}向いている: 普段は cargo check を軽く回しつつ、 commit 前 / CI で cargo xtask lint を呼ぶ運用。 rust-analyzer / rustfmt / clippy も含めた dev タスクの集約場所として使える。3. pre-commit hookpre-commit や .git/hooks/pre-commit で commit 前に走らせます。# .pre-commit-config.yaml- repo: local  hooks:    - id: my-lint      name: my custom rowan lint      entry: cargo run --quiet --release --bin my-lint --      language: system      files: \.rs$向いている: ローカル開発で違反コードを commit させない ガード。 CI に出る前の最後の壁。4. CI stepGitHub Actions / GitLab CI で cargo run --bin my-lint -- src/ を 1 ステップ足すだけ。- name: rowan lint  run: cargo run --release --bin my-lint -- src/向いている: 公的に強制したい lint。 PR ごとに必ず走る。どれを選ぶか実務での手堅い組み合わせは xtask + CI step。 普段 cargo check は軽いままにしつつ、commit 前後で必ず走るルートを 2 本確保します。 「絶対に違反 commit を許さない」なら + pre-commit hook。 build.rs は IDE のリアルタイムループに lint を混ぜたい強い理由があるとき以外は避けるのが無難。シリーズ末尾でよく書いてきた Cargo.toml の clippy 設定:[lints.clippy]unwrap_used = "deny"manual_let_else = "warn"これと 同じ強制力を、自家製 lint にも持たせる のがこの節の狙いです。いつ rowan を選ぶかrowan が向いている場面と、それ以外で済む場面の境界は明確です。 やりたいこと  第一候補  cargo check 相当の型検査  rustc / rust-analyzer (自分で書かない)  一般的な書き方の修正  clippy  マクロでコード生成  syn  コードベース固有の規約を CI で機械的に弾く  rowan (ra_ap_syntax)  コメント・空白を保ったままの書き換え  rowan (フォーマッタ系)  自分の DSL のパーサーを書く  rowan + 自前 grammar shitamono の grammar 工事までやるのはコストが大きいので、 Rust に対する lint なら ra_ap_syntax 経由で rowan を使う が実務的です。注意点ra_ap_syntax のバージョン: rust-analyzer の内部に追従して頻繁に上がります。0.0.x 表記で minor が動くと API が変わるので、 lint プロジェクトでは Cargo.lock をコミットして固定します。Edition: SourceFile::parse(src, Edition::Edition2021) のように edition を渡します。Edition で grammar が一部変わる (let chain など) ので、 2024 のコードを 2021 でパースすると一部 syntax error になります。検査対象のクレートに合わせて選びます。型情報がないこと: rowan は構文木のみ。 「.clone() の receiver が本当に Arc<T> か」のような 型情報 は判定できません。型情報まで欲しいときは rust-analyzer 内部の HIR を借りるか、別の手段 (rustc_driver プラグインなど) になります。 syntactic でも書ける lint は意外に多いので、まずは rowan で書ける範囲を試すのが速いです。まとめrowan は rust-analyzer の心臓部にある ロスレスな構文木ライブラリsyn と違い、 空白・コメント保持 と resilient parsing ができるRust の grammar は ra_ap_syntax 経由で借りられるドメイン規約 (unwrap 禁止、#[non_exhaustive] 強制、newtype 推奨、lazy_static! 撤去など、シリーズで紹介してきたもの) は、 rowan で書いて CI に乗せると壊れないclippy で済むなら clippy、コードベース固有なら rowan、と使い分けるシリーズで言葉にしてきた「こう書こう」を、 コンパイラに守らせる ところまで連れて行く道具が rowan です。手元の tools/rbp-lint もこの上で書いています。]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[31歳、もちろん俺らは抵抗するで？筋肉で]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/10/171224</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/10/171224</guid>
            <pubDate>Sun, 10 May 2026 08:12:24 GMT</pubDate>
            <content:encoded><![CDATA[はじめに※この筋肉はイメージであり現実の肉体とは乖離がある場合があります。ある誕生日の朝、通知が、なかった。母親からだけ、メッセージが来ていた気がする。俺の人生はLINEの量で決まるかと嘯きながら、LINEの誕生日表示を非公開にした。20代の、どこかの誕生日だったと思う。そして、31歳になった。独身。家族と住んでもいない。そういう男を、誰も守ってはくれない。LINEはくれないが友人のような人たちも、給料はくれるが人生の責任は取らない会社も、ドラフトはくれるが長期の成長までは背負わないAIも、最後のところでは助けに来ない。自分ぐらいは、自分で守る。残された守り方は、筋肉だった。念のため書いておく。社会にも会社にも、友人にもAIにも、不満があるわけではない。それぞれが、それぞれの範囲で、ちゃんと支えてくれている。ただ、最後のところで自分の身体を引き受けるのは、構造的に、自分しかいない。それだけの話だ。もし社会の側が、独身男性の身体を最後まで抱える構造を作れば、私はこの線を引き直す。今のところ、その兆しは見えない。最近の朝は、だいたいこうだ。シェイカーに水を入れ、粉を入れ、蓋を閉めて、振る。粉が水に消えていく、その瞬間を見るのが、少しだけ好きだ。この一杯が、昨日の私の身体を、今日の私の身体に、わずかに繋ぎ直す。ノートPCには、前夜に書きかけたコードへのレビュー差分が並んでいる。寝ている間に、AIがもう一度コードを通してくれていた。プロテインを飲み干す。喉の奥が冷たい。窓の外はまだ暗い。これは、抵抗の話だ。ただし戦闘の話ではない。鶏肉と卵を切らさない。バーベルを担ぐ。寝る時間を最初に取る。筋肉は、自分の力でなんとかなる、数少ない臓器だ。心臓も肝臓も腎臓も、自分の意思では動かせない。筋肉だけは、毎日の動作で確実に変えられる。その臓器を、毎日世話する。それを、抵抗と呼んでいる。何に対して抵抗しているのかは、最後のほうで書く。一年前、30歳の扉の前でうろたえる男の独白を書いた——「20代最後の一週間を生きるエンジニア、あるいは30歳の扉の前でうろたえる男の独白」と「中学17年生」。あれから一年。扉は、結局、通り抜けた。通り抜けた先で何が起きたかを書いておきたい。ありがたいことに、ここ一年で、ソフトウェアエンジニアリング以外の仕事が少しずつ増えてきた。技術や組織の話を書いたり、人前で話したり、誰かの相談に乗ったり、本を書くことになったり。手より、口や言葉で関わる仕事の比率が、ゆっくり上がってきた。30歳の節目に書いた一本のブログから、思いがけず広がった機会だ。気がつくと、20代までは「コードを書く人」だった私が、少しずつ「考えを言葉にして渡す側」にも寄ってきている。これも、30代に入ってからの変化のひとつなのだろう。同時に、若いからって呼ばれることが、はっきり減った。20代の頃は「若手の代表」のような枠で声がかかることがあった。今は、その枠で呼ばれることはほぼない。呼ばれるなら、内容で呼ばれる。若さは、いつのまにか持っていた切符だった。誇らしいというより、地続きの責任が増えた、という意味のほうが大きい。それから、もう一つ気づいたことがある。20代の頃は、同年代の誰もが、まだ友達を探していた気がする。新しい関係を入れる余地が、空気の中にあった。30歳を過ぎてから、誰も、もう友達を探していないように見える。各々が、各々の生活をすでに固めていて、新しい関係を入れる隙間が小さい。誰かが冷たくなったわけではない。新しい友達を作ろう、というのは、ある程度、諦めたほうがいいのかもしれない。ただ、これも、たぶん視野の狭い、適当な意見だ。友達がいなくなるのではなく、友達の形が変わるだけ、というのが、たぶん正確に近い。そして、その形は、どんどん変わっていく。子供の頃のような毎週会う友達は減って、年に数回しか会わない、でも会えば仕事や家族や身体の話を真面目にする友達が、別の形で増えていく。書きにくいことを、ここで書いておきたい。男性同士の関係には、ケアの言葉が驚くほど少ない。「元気か」も、「無理してないか」も、「身体に気をつけろよ」も、私たちはあまり口にしない。言われたら、心の奥では刺さっているのに。私もそうだ。考えてみれば、これは個人の性格の話ではない。男はケアの仕方を、ちゃんと教わってきていない。「我慢しろ」「弱音を吐くな」と育てられた人間が、急に他人をケアできるわけがない。男性同士の友情が、仕事や酒や趣味といった活動の上にしか成り立たないのも、感情を交換する練習が、私たちにはあまりにも足りていないからだ。男は自分の弱さを、恋愛関係の側だけに預けがちだ——その依存が、独身の30代を急に孤立させるカラクリでもある。それで、30歳を過ぎてから、私は同年代の男友達に、ふと「最近、ちゃんと寝てるか」と聞くようになった。たいした言葉ではない。男同士の友達の形が変わる、というのは、たぶん、互いをケアする言葉を、ぎこちなく持ち始める、ということでもある。LINEの誕生日通知があまり鳴らないのは、その変化の途中の音だ、と思っている。別の戦いも始まった。老化と、老いと、パフォーマンスの低下。仕事の幅が広がるほど、身体ひとつにかかる負荷が増える。20代の頃のように、勢いと体力で押し切れなくなっている自分がいる。思考はどんどんAIに任せられるようになった、その代わりに、身体だけは誰にも任せられない。本は刺激にはなるが、骨格にはならない。骨格は、自分の生活でしか作れない。長い記事になる。気になる文章だけ拾ってもらってかまわない。どこかで私の話ではなく、あなたの話に近づくかもしれない。このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。20代の貯金が切れる、そして引き算が始まる私は20代前半まで、自分の身体に何の注意も払っていなかった。寝なくても回復した。深夜にラーメンを食べても太らなかった。徹夜明けに登壇しても、コードレビューでも、それなりに頭は動いた。20代前半の身体は黙って働いてくれた。20代の後半からは無理が身体に現れて如実に太った。しかし体力はあった。私はその恩恵を、自分の能力だと勘違いしていた。20代の後半から30歳にかけて、それが変わった。階段を上がると息が切れる。駅の改札から会議室まで歩くだけで、ワイシャツの背中が湿る。デスクワークの腰は、午後三時を過ぎると身体は重く少し眠い。夜の集中力は、20代の頃のように勝手には戻ってこない。コードを書いていて、ふと、画面の文字が滲む。眼精疲労ではない。たぶん、もっと別の何か——身体の側の慢性的な疲労が、視覚に現れている。健康診断の結果も普通に悪い。オールAだったことは、たぶん、もうずいぶん前の話だ。年に一度、結果を開くたびに、引っかかる項目が一つか二つずつ増えていく。「経過観察」「要再検査」と書かれた紙を見て、その日のうちに忘れる、という処理を何年か繰り返してきた。自分の身体が出している信号を、自分で握りつぶしてきた。握りつぶされた信号は、消えるのではない。別のところで、もっと強く出る。後で知ったことだが、糖尿病も、心血管系の問題も、認知の問題も、健康診断の数字に出る前から、骨格筋のレベルですでに進行している。骨格筋は身体の総質量のかなりの割合を占めていて、血糖の処理も、ホルモンの一部も、ここで決まっている。健康診断は、後半戦の通知だ。本当の勝負は、もっと前から、自分の筋肉と脂肪の比率の中で動いている。30代以降、意図的に介入しない人間の筋肉量は、毎年わずかずつ減っていく。サルコペニアという名前がついている現象だ。私はそれを「50代からの話」だと思い込んでいた。けれど、自分の身体で確かめてみると、30代の今この瞬間から、引き算はもう始まっている。何もしなければ、全員が確定的に向かう未来だ。私が始めたのは、その引き算に対する、抵抗の予算化だった。介入してみても自分の身体に変化が見えなければ、私はこの線を引き直す。今のところ、変化のほうが先に出ている。この引き算の中身を覗くと、見えてくるのは脂肪の多さではない。肥満の流行ではなく、中年の筋肉危機——という言い方を、私は最近採用している。見えている脂肪を相手にしているうちに、見えていない筋肉のほうが先に減っている。戦いは、見えるところより先に開いている。介入しなくても遺伝的にあまり減らない人もいる、というのは、たぶん事実だろう。ただ、私はそれが自分にも当てはまるか、賭けに出るほどの根拠を持っていない。自分が外れ値である可能性に賭けるより、平均値の側で対策を取るほうが、私には合っていた。これは私の判断の話で、誰かの判断の話ではない。事情も体質も、人によって違う。20代までの私は、何もしなくても積み上がっていた。30代の私は、何かをしなければ崩れていく。ベースラインが、足し算から引き算に変わった。ここで、はっきり書いておきたい。筋肉は完全に貯金だ。未来に向けた貯蓄だ。それも、現金にはできない、自分の身体でしか引き出せない種類の。老後資金を真面目に積み立てているのに、筋肉のほうの積み立てはやっていない人間が、私には本当に意味が分からない。金の貯金は20年、30年の長期で語られる前提があるのに、身体の貯金だけ「今の若さで足りる」と思い込んでいる。同じ未来の話のはずだ。片方だけ防御を固めて、片方は無防備で歩いている。老後に金は残ったが、立てる脚は残らなかった、という未来を、たぶんみんな想定していない。これに気づいたのは、ある夏の朝のことだった。鏡の前に立って、「これは20代の俺じゃない」と思った。たぶん、誰でも一度はその瞬間がある。違いは、その瞬間に何をするか、だ。多くの人は気づかなかったことにする。私は気づいた振りで終わらせなかった。今年に入ってから、本格的に取り組み始めた。それなりに脂肪は落ちた。書きたいのは落ちた量ではなく、この数ヶ月の中身が、ただの減量ではなかった、ということだ。脂肪を落としながら、筋肉を可能な限り落とさない——これが目指していたことだ。やってみて分かったが、これが減量の本体だった。脂肪を落とすだけなら、食べなければいい。難しいのは、その隣で筋肉を守ることのほうだ。脂肪を落としながら筋肉を守った末の体重と、ただ食べる量を減らして落ちた体重は、同じ数字でもまったく別の身体になる。体重計の数字は、たぶん、いちばん粗い指標でしかない。派手なのは入り口で、地味なのは中身、というやつだ。毎朝同じ時間に起きて、鶏肉と卵でタンパク質を入れて、同じ時間に寝る。その積み上げで、勝手に数字は動いた。動かそうとしたのではなく、動いた。動くように仕組みを組んだだけだ。健康に投資すると決めたとき、二つの予算が動いている。短期は、プロテインやサプリ代と、本を読む時間。これは見えやすい。長期は、毎日同じ時間に、鶏肉と卵でタンパク質を入れ続けるという、見えない時間の積み立て。短期だけ取った人は続かない。長期だけ取ろうとした人は始まらない。私が今やっているのは、両方を、それぞれの予算枠から取ることだ。落ち始めたあと、これが続くかは、まだ分からない。減量の本当の難しさは、落とすことではなく、戻さないことのほうにあるらしい。リバウンドが怖い、と書くと弱気に見えるかもしれないが、怖いと書いておくのは、自分のためだ。怖いと書いた人間は、たぶん、戻さない側に少しだけ寄れる。先に断っておくと、ジムにもスポーツにも、ずっと身を置いてきた人間だ。10代はラグビー少年だった。タックルとスクラムの中で、自分の身体が他人の身体とぶつかる感覚を、毎週末覚えた。20代の前半は格闘技をやっていた。打撃も寝技も、頭よりも身体で覚えた数年だった。コロナをきっかけに、ジムや道場に通えなくなり、そのまま流れで引退した。身体を使う場所を失ってから、私の食事は確実に雑になった。動く理由が抜けたのに、食う癖だけが残った、という典型的な順番だ。動かなくなった身体は、サボることを覚える前に、食うことを覚える。だから、私が30歳の節目で始めたのは「運動」ではない。筋肉は、それなりにあった。運動はやっていた。壊滅的だったのは、食事のほうだ。深夜のラーメン、コンビニ飯、〆の炭水化物、酒。動いていれば食べてもいい、という雑な釣り合いの取り方を、ずっとやってきた。動いている分のカロリーで帳尻を合わせている、という錯覚があったが、現実は違った。動いている上に、食事が雑だっただけだ。筋肉の上に、たっぷりの脂肪が乗っかっている、というのが20代終盤の私の身体だった。だから30歳で始めたのは、ジム通いではなく「食事を、自分の身体の側に取り戻すこと」だった。動機は高尚ではない。動いている自分は知っていた。食べている自分が、ずっと足を引っ張っていた。それに、ようやく気づいた。問いを書き換える何冊か本を読んで、ジムに通って、トレーナーと話して、自分の身体で確かめて、私の中で一つの問いが書き換わった。それまで、身体に関する問いは「どうやって痩せるか」だった。脂肪をどう減らすか、カロリーをどう抑えるか、体重計の数字をどう下げるか。30代になってから「健康」を意識した、と人が言うとき、たいていの場合、それは「ダイエット」を意識した、という意味だ。私もそうだった。書き換わった先の問いはこれだ。太っているのではない。筋肉が足りない。最初は、何を当たり前のことを、と思った。考え直していくうちに、これは当たり前ではなく、医療と日常の発想を逆さにする話なのだ、と分かってきた。筋肉を、見た目のための装飾としてではなく、代謝・血糖コントロール・免疫・メンタルの臓器として捉え直す。筋肉は、長く健康でいるための、いちばん大きな臓器だ。私の言い方に直すと、こうなる。この一文で、問いが書き換わった。「どうやって痩せるか」ではなく「どうやって筋肉を増やすか」。前者の答えは「食べる量を減らす」だが、後者の答えは「正しく食べて、正しく動く」になる。前者で減るのは脂肪と一緒に筋肉も、だ。後者で増えるのは筋肉、減るのは脂肪だけ。問いの立て方が違うだけで、答えはまったく別の場所に出る。私は20代を通して、運動の側だけを真面目にやっていた。ジムにも行っていた、スポーツもしていた。動く側は積み立てていたのに、食べる側を放置していた。動けば食べていい、という雑な等式で、身体に対する責任を半分しか負っていなかった。問いが「動けばいい」だった限り、食事はどうでもよかった。問いが「筋肉を増やす」に変わって、初めて食事が、運動と同格の主役として立ち上がってきた。問いを書き換えると、それまで脇役扱いしていたものが、急に主役になることがある。エンジニアリングと同じだ、と思った。間違った要件に対して、どれだけ綺麗にコードを書いても、誰も使わないものができる。問いを書き換えれば、書くコードも変わる。30代の身体に対しても、問いを書き換える必要があった。「ダイエット」をやめる。「筋肉を増やす」をやる。たったそれだけのことだが、これが私の30歳から31歳の一年で起きた、一番大きな転換だった。鏡を見るのをやめる。体重計の数字を見るのをやめる。代わりに、扱える重量と、毎食のタンパク質を見る。指標を変えれば、行動は勝手に変わる。問いを書き換えるところまでは、頭でできる。次にやるのは、その問いを、追える形に落とすことだ。仕事と同じだ。仕事のゴールを「成果を出す」とだけ書いたチケットは、たぶん完了しない。完了したかどうかを判断する基準がないからだ。具体的でない、計測できない、達成可能性が見えない、自分の責任範囲か曖昧、期限がない——そういうチケットは、忙しい週に必ず一番後ろに回る。だから仕事では、ゴールをSpecific（具体的）、Measurable（計測可能）、Achievable（達成可能）、Relevant（関係している）、Time-bound（期限付き）の5つに分解する。SMART、と呼ばれている枠組みだ。新しい話ではない。ただ、効く話ではある。私はこれを、身体にも適用することにした。「筋肉を増やす」だけでは追えない。「半年で、スクワットの重量を◯キロ上げる」「3ヶ月で、毎食のタンパク質を◯回切らさない」——このくらいまで降ろして、初めて、毎週の自分が、進んだのか後退したのかを判断できる。具体的な数値を、ここではあえて書かない。私の処方であって、あなたの処方ではないからだ。書いておきたいのは、形のほうだ。身体の目標も、仕事のチケットと同じ粒度まで落とす。これだけで、続けやすさが一段変わった。タンパク質を、考えるより先に食う食事の話を書く。ただし、私が何グラム食べているかは書かない。それは私の身体に対する処方であって、読んでいるあなたの身体に対する処方ではないからだ。書くのは、考え方のほうにする。トレーナーと話したり、いくつかの本に当たったり、自分の身体で試したりして、最後に私の中に残った原則は、ひとつだけだった。1食ごとに、筋合成のスイッチを入れる閾値を超えるタンパク質を摂る。それだけだ。このスイッチには下限がある。下限を下回ると、何回食べてもスイッチは入らない。下限を超えれば、スイッチは入る。仮に1日の合計が同じでも、ちょこちょこ少量ずつ食べるのと、まとまった量を一定間隔で食べるのとでは、筋肉が受け取るシグナルの回数がまったく違う。1日の総量を10回に分けて少しずつ食べても、スイッチが0回しか入らないなら、筋肉は守れない。同じ総量を、閾値を超える形で何回かに分ければ、その回数だけスイッチが入る。量ではなく、閾値を超えた回数で筋肉は守られる。これを知ってから、私の食事は単純になった。1食を組み立てるとき、最初に決めるのはタンパク質の主役だ。卵か、鶏か、牛の赤身か、白身魚か。それを決めてから、副菜と糖質を組み立てる。組み立ての順番が逆だった頃の私は、「今日は何を食べようか」と考え、結果としてラーメンや丼ものに着地していた。主役を後回しにすると、主役は席に着けない。朝が一番大事だ、というのが、私の中での結論になった。寝ている間、身体は燃料を求めて、最初に筋肉を分解しにかかる。朝、起き抜けにスイッチを入れることで、その分解を打ち切る。だから私は、朝、まず卵と鶏肉を食べる。考えるより先に食う。考えるとサボる人間だと、自分のことを知っているからだ。鶏肉と卵だけは、毎日切らさない。これが私の食事の核にある。「考えるより先に食う」を仕組みにするために、私は休みの日にまとめて作っている。鶏胸肉は週の頭に茹でて小分け冷凍する。ゆで卵は常備する。冷蔵庫を開けたときに、決断の余地を残さない。意思の力で食事を続けるのは、意思の力で運動を続けるくらい、長くは持たない。続けるのは仕組みのほうだ。夜寝る前にもう一度、タンパク質を入れている。これは消化が遅いタイプのもの——カッテージチーズ、ギリシャヨーグルトの類——を選ぶ。睡眠中の何時間か、体は燃料を求め続ける。そこに穏やかにタンパク質が供給されれば、筋肉を分解しに行かなくて済む。寝る前のタンパク質は、明日の自分への投資ではなく、昨日までの自分の保全だ。技術書を読んだあとに「明日からこれを試そう」と思うのと、栄養学の本を読んだあとに「明日からこれを食べよう」と思うのは、私の中では同じ動きをしている。新しい知識を、生活に翻訳する作業。エンジニアの仕事は、抽象を具体に翻訳することだ。身体への投資も、抽象を具体に翻訳することだ。地続きだった。整えてみてわかったのは、運動より食事のほうが、組み立て直すのは難しい、ということだった。運動は時間を確保すればやれる。食事は、起きている時間ずっと、選択を迫られ続ける。運動は気合いで始まるが、食事は仕組みでしか続かない。これは、運動を先にやっていた人間ほど、見落としやすい話だと思う。それからもう一つ、最近自分に言い聞かせていることがある。サプリで食事の悪さは取り戻せない。プロテインも、ビタミンも、私はちゃんと飲んでいる。ただ、それは足りない部分を埋めるための補助であって、雑な食事の上書きにはならない。サプリを飲んでいる、という事実が、食事を雑にする言い訳になっていた時期がある。今は逆に、サプリを飲むなら、食事のほうが先、という順番で生活を組み立て直している。重いものを担ぐ、そして休むトレーニングの話を書く。これも、何キロ挙げているかは書かない。重量は私の現在地でしかなく、読者にとっての処方ではないからだ。書くのは、なぜ続いているか、のほうにする。私は週に何度か、ジムに行ってバーベルを担いでいる。種目は地味だ。スクワット、デッドリフト、プレス系。コンパウンドリフトと呼ばれる、複数の関節を同時に使う種目を中心にしている。ベンチで腕を太くするとか、マシンで形を整えるとか、そういう「見せる」ための種目は後回しにしている。先にやるのは、身体の中心にある、大きな筋肉を強くすることだ。なぜか。理由は単純で、大きい筋肉から鍛えたほうが、効率がいいからだ。大腿、背中、胸。これは身体の総筋量の大半を占める。ここを強くすれば、代謝も、姿勢も、日々の動きの楽さも、全部底上げされる。装飾ではなく構造から手を入れる。エンジニアリングの優先順位の付け方と同じだ。表層をいじる前に、データモデルを直す。それと同じ発想で、身体の表層をいじる前に、身体の構造を直している。ここで、トレーニングの「型」を簡単に分けて書いておきたい。何を選ぶか、ではなく、何があって、何を選ばなかったか、の話だ。大きく分けて、三つある。筋力トレーニング、持久力トレーニング、高強度インターバルトレーニング（HIIT）。それぞれが、身体の別の側面に効く。私は最初の一つに比重を置いているが、他の二つを否定しているわけではない。筋力トレーニングは、重い負荷を、少ない回数で、間を取りながら扱う型だ。バーベル、ダンベル、自重で大きな抵抗をかける。狙うのは、筋繊維の太さと、神経が筋繊維を動員する効率の二つだ。数回がぎりぎり挙がる重量で、複数セット組む。セット間は数分しっかり休む。長い目で見ると、骨密度、姿勢、日常動作の楽さに効いてくる。装飾ではなく構造を作る型だ。私が比重を置いているのは、これだ。持久力トレーニングは、中くらいの強度を、長く続ける型だ。ランニング、サイクリング、水泳、長めの歩行。心臓、肺、血管、毛細血管、ミトコンドリアに効く。30分から1時間以上のレンジで、息は上がるが会話はできるくらいの強度を、淡々と続ける。代謝の底上げと、回復力の土台になる。心肺の側のインフラを敷く型だ。高強度インターバルトレーニングは、短い全力と、短い休憩を交互に組む型だ。HIITと略される。20秒から数分の全力と、それより短い休憩を交互に挟み、数セット繰り返す。心肺と無酸素能力の両方を、同時に短時間で刺激する。時間効率の型、と言っていい。30分のジョギングと、数分のHIITが、ある側面では拮抗する。短い分、フォームが崩れた瞬間に怪我が来る。代償は時間ではなく、関節で払う型だ。HIITの中で、いちばん知られているのがタバタ式だ。日本人研究者の田畑泉が、スピードスケート選手のトレーニング効果を計測する中で見つけた方法、と聞いている。20秒全力 + 10秒休憩 × 8セット = 4分。たったの4分だが、最後の数セットは、身体がほとんど動かなくなる。有酸素能力と無酸素能力の両方に同時に効く、というのが当時のデータの売りだった。ジムに行けない日に、家のマットの上で4分だけタイマーを走らせる、というのが、私の使い方だ。気休めかもしれないが、何もしないよりは確実に違う。並べてみると、それぞれの型は、別の臓器に投資している、と言える。筋力は筋繊維と神経に。持久力は心肺と血管に。HIITはその両方に短時間で。一つで全部はカバーできない。だから、優先順位の話になる。私は30代の身体に対して、まず構造を作るほうから手を入れた。心肺は、その上に乗せていく順番で構わない、と判断している。これも、私の判断の話で、誰かの判断の話ではない。ただし、ここまで型を並べておいて、最後に一つだけ、誰の判断とも関係ない話を書いておく。運動の方法はいろいろあるが、絶対に間違っている方法が一つだけある。まったく運動をしない、という方法だ。どの型を選んでも、選ばないよりは正しい。順番を間違えても、やめるよりは正しい。型の議論は、その手前にある「やる／やらない」の線を越えてから、初めて意味を持つ。回数はそれほど多くない。週に何度か、と書いた通り、毎日ではない。むしろ、毎日やってはいけない、というのが、ジムのトレーナーから何度も言われたことだった。筋肉が育つのは、ジムにいる時間ではなく、ジムを出てから次にジムに行くまでの時間だ。回復している時間に、身体は強くなる。トレーニングは刺激を入れる行為で、肥大は休んでいる間に起きる。私はこれが、最初は受け入れられなかった。「やれば伸びる」と思っていた。エンジニアの世界の感覚に近い。手を動かした分だけ、コードは進む。だから、身体も手を動かした分だけ強くなるのだろう、と。違った。やりすぎた身体は伸びない、止まる。場合によっては怪我をして、全部巻き戻る。それで私は、トレーニングをしない日を、トレーニングと同じくらい真剣に設定するようになった。週に一日、何もしない日がある。何もしないと書いたが、正確には軽く歩いたり自転車で読書したりしている。心拍数を上げない、軽い動きの日だ。これを「休み」と呼ぶか「アクティブレスト」と呼ぶかは好みだが、共通しているのは、休むことを、サボりではなく戦略として置く、ということだ。「おい、休め」と一年前の自分に書いた。あれは、仕事の話のつもりで書いていた。今、私はそれを身体の話としても書き直している。文脈は違うが、構造は同じだ。回復に投資しない人間は、長く続かない。短期で成果を出して、燃え尽きるか怪我をするかで終わる。仕事も身体も、回復にこそ予算を取る。ジムに行くことの副次効果として、メンタルへの効きが、思っていたより大きかった。気分が落ちている日にバーベルを担ぐと、終わったあと、なぜか気分が戻っている。ある夕方、頭の中がざらざらしている日にジムに行った。3セット目のスクワットで、太腿が震えた。バーを置いてしゃがみ込んだ瞬間、床のゴムマットの目地が、やけにくっきり見えた。終わってシャワーを浴びると、湯の当たる肩が、すこし滲むように熱かった。気分が戻ったというより、気分のことを忘れていた。これは、頭で考えるだけでは絶対に得られない種類の経験だ。なぜそうなるのかの機序は、私には説明できない。専門家ではないからだ。ただ、自分の身体で何度も繰り返し起きることとして、重いものを担ぐと、気分が後からついてくる、ということだけは確信している。筋肉は、ただ動くための装置ではない。動かすたびに、身体の中で何かが出ている。それがメンタルにも、血糖にも、免疫にも効いている、と私は今思っている。細部の機序は説明できない。ただ、自分の身体は、自分の中で薬を作っている臓器でもある、というのが、いま私が採用している言い方だ。気分が落ちた日ほど、ジムに行く価値がある——それは、たぶんこの話と地続きだ。ジムに行く前は「行きたくない」と思っている。行ったあとは「行ってよかった」と思っている。この5回中5回くらいの、ほぼ確定で結果が逆転する現象に、私は何度も助けられている。自分の予想を、自分の身体が裏切る。これは、頭で考えるだけでは絶対に得られない種類の経験だ。肩を痛めている時期もあった。今もまだ完全には抜けていない。痛みがあるなら、可動域を狭くする、角度を変える、必要なら重量を下げる、ストレッチを毎日やる。痛みを無視して挙げる人間は、必ず一回大きく崩れる。30代の身体に、根性は通用しない。通用するのは、観察と微調整だけだ。観察と微調整、と書いた。観察する対象は、痛みだけではない。「やっているのに、結果が出ない」と感じる時期にも、観察する場所がある。私の経験では、その状況は、だいたい二つのどちらかで説明がつく。毎回同じ負荷で動かしているか、決めたことを決めた通りに実行できていないか、のどちらかだ。一つ目は、漸進性の話だ。先週と同じ重量、同じ回数、同じセット数を、今週も来週もやっていれば、身体は適応を止める。最初は伸びる。数週目あたりから止まる。そこで「自分は伸びないタイプだ」と判断するのは早い。伸びるのは、刺激が変わったときだけだ。1キロでいい、1レップでいい、フォームの精度でもいい。何かが先週より重い、というシグナルを、毎週、身体に送り続ける。これをやっていなければ、結果は出ない。出ないのが正しい。二つ目は、決意の話だ。やる、と決めたことを、決めた通りに実行できていない。週に何度か、と決めたのに、実際は週に一度しか行かない週がある。タンパク質を毎食、と決めたのに、ランチが菓子パンになっていた日がある。私もだ。決意が足りない、と書くと根性論に聞こえるが、現実はもっと地味だ。やっていない部分を、自分は把握していない。把握していない不足は、修正できない。だから、雑でいいから、自分の実行履歴を毎週書き留める。私がやっているのはこれだ。結果が出ない、と思った週は、たいてい、計画と実行の間に、静かな差分が積もっている。「身体が悪い」「年齢のせいだ」「自分は例外だ」——結果が出ないとき、私はこれらの説明をしがちだった。後から実行履歴を見返すと、たいていは負荷の据え置きか、計画の未達か、その両方だった。身体は素直で、嘘をつかない。嘘をつくのは、自分の側の記憶のほうだ。寝る時間を最初に取る、残りで全部やるここまで、食事と運動の話を書いてきた。最後に、私の中で一番優先順位が上がったものについて書く。睡眠だ。20代の頃の私は、睡眠を「余ったらやるもの」として扱っていた。仕事が終わってから、コードを書いて、本を読んで、SNSを眺めて、最後に時間が残っていれば寝る。残らなければ、削るのは睡眠だった。睡眠を削ることに罪悪感はなかった。むしろ「自分はよく頑張っている」という感覚さえあった。30歳に入ってから、これを完全に逆にした。寝る時間を先に決める。残った時間で、仕事も、コードも、本も、運動も配分する。睡眠を予算の最後ではなく最初に置く。この発想転換は、トレーニングと食事をちゃんと噛み合わせ始めてから自然に起きた。寝ていない翌日にジムに行くと、扱える重量が落ちる。集中力が落ちると、フォームが崩れる。フォームが崩れると、怪我のリスクが上がる。だから、トレーニングの質を保つために、睡眠を削れなくなった。身体を運用すると決めた瞬間、睡眠は手放せないものになる。それだけではない。睡眠不足の日は、食欲がおかしくなる。空腹のシグナルが過剰に出て、糖質の高いものに手が伸びる。これはホルモンの話で、私の意思の弱さの話ではない。寝ていない人間に、正しい食事の選択は難しい。睡眠は、食事の上流にある。仕事も同じだ。寝ていない日にコードを書くと、変なバグを生む。寝ていない日に設計をすると、後から見て筋の悪い判断をしている。寝ていない日にレビューをすると、本来指摘すべきことを見逃す。寝ていない人間に、まともな仕事の判断は難しい。睡眠は、仕事の上流にもある。睡眠は、トレーニングと食事の効果を、後ろから引き伸ばしたり台無しにしたりする。これは自分の身体で、何度も実感した。寝ている時間の長さと質で、翌日の重量も、食欲も、頭の冴えも、はっきり変わる。身体は、寝ている間に作られる。起きている間にやっているのは、刺激と材料を入れる作業だ。組み立てるのは、寝ている間の自分だ。私は今、夜の同じ時間に寝るようにしている。何時に寝ているかは書かない。書いたところで、人によって最適な時刻は違う。書くのは、ルールのほうだ。毎日、同じ時間に寝る。同じ時間に起きる。週末も、できる限り崩さない。崩した次の月曜が、私にとっての敵だからだ。寝る前のスマホを置く、という話を、私は一年前に「おい、スマホを置け」というタイトルで書いた。あれは、注意の話のつもりで書いていた。今、私はそれを睡眠の話としても書き直している。寝る前のスマホは、明日の自分のトレーニングを台無しにする。文脈は違うが、結論は同じだ。休むことが、最強の戦略だ、ということを、30歳の一年で学んだ。「おい、休め」「おい、がんばるな」と書き連ねてきたが、書いた本人が一番わかっていなかった。書いた言葉が、書いた本人を後から育てることがある。私の時は、これだった。ドラフトはAIに、引き受けは自分にここまで、健康と筋肉の話だけを書いてきた。31歳のブログとしては、それだけでも書ける。ただ、私が一番書きたいのは、ここから先の話だ。なぜ、今このタイミングで、この話を書いているのか。理由は、生成AIの進化が、私の予想を完全に超えたからだ。去年の今頃、私は30歳の節目の記事で「専門性を深めろ」「失敗から学べ」「人間関係をやれ」と書いた。あれを書いていた頃の私は、AIが今のように当たり前のように仕事に入ってくることを、想定はしていたが、速度を完全に読み違えていた。一年でこれほど深く入ってくるとは思っていなかった。今、私は、コードのドラフトをAIに書かせる。設計の壁打ちをAIとやる。文章の校正をAIに通す。調査の最初の足がかりはAIに作らせる。仕事の速度は、確実に上がった。速度と引き換えに、別の何かを差し出している、という感覚もある。正直に書く。生成AIが書いてくれたコードを、自分が完全には理解しないまま、リリースまで持っていく日が、ある。動いている。テストも通っている。レビューしたエージェントも問題なしと言っている。だが、私の頭の中に、そのコードのメンタルモデルが、まだ立ち上がっていない。最近もあった。AIが書いたエラーハンドリングの分岐を、レビューで通したあとに、別の人から「これ、何でこの順番なんですか」と聞かれて、答えられなかった。動いていた。テストも通っていた。ただ、私は『なぜ』を持っていなかった。その場で慌てて差分を読み直して、なんとか説明っぽいものを返したが、自分の中で、何かが静かに敗れた音がした。動くコードと、答えられない私が、同じ画面の中にいた。最近、これに名前がつくようになった。認知負債、と呼ぶ人たちが出てきた。技術的負債と並べて、コードではなく、人間とチームの側に積もる負債として整理されている。さらに細かく分ければ、コードの保守性が落ちる「技術的負債」、人の頭の中とチームの共有理解が劣化する「認知負債」、コードはあるが「なぜそうしたか」が外部化されていない「意図負債」、の三層になる。AI時代に一番ひそかに積もるのは、たぶん、最後の二つだ。コードは生成できる。動くものはすぐ手に入る。ただ、「なぜそうしたか」を残す手間は、生成では肩代わりされない。むしろ、生成が速いほど、「なぜ」を残す側が追いつかなくなる。AIが書いたものをマージし続けると、コードの量は増え、頭の中の地図は薄くなる。地図のないコードベースを、私たちは静かに作り続けることができる。私の中で、これは身体の話と地続きだった。サルコペニアと似た構造をしている。毎年わずかずつ、確定的に減っていく。何もしなければ、減る方を選んでいることになる。身体の衰えと、コードの理解の薄れと、判断の解像度の鈍りは、別の臓器で起きる別の現象だが、進み方の形がよく似ている。AIが書いてくれたコードを、ただマージし続ける日々は、自分で考える機会を、自分から取り上げ続ける作業だ。取り上げ続ければ、考える側は静かに鈍る。別の角度からも、同じ話が見えている。自動化されればされるほど、人間の側に残るのは、自動化できなかった残余の、もっとも重く、もっとも判断の難しい仕事だ。普段は監視役に回り、いざという時にだけ介入を求められる。だが、監視役に回り続けた人間は、いざという時に、もっとも準備ができていない。これは、AIが来る前から私が見てきた現場の構造でもある。手を動かさなくなったエンジニアが、いざ動こうとすると、もう動けなくなっている、という光景を、何度か目撃した。AIが書いたコードを眺める時間が長くなるほど、自分でコードを書く地力は鈍る。AIが要約した本を読む時間が長くなるほど、自分で本を読み解く地力は鈍る。AIが下書きしたメールを直すだけの日が続くほど、自分で文章を組み立てる地力は鈍る。これは、私自身が、すでに何度か感じている。じわじわ、で済む話ではない。確定的に、減る側に流れている。範囲を切っておく必要がある。この話が当てはまるのは、ある程度の専門性をすでに持っていて、自分でコードを書ける地力がある人間に対してだ。まだ地力を持っていない人にとっては、AIに頼る期間が学習期間になりうる。問題は、地力を持っていた人間が、それをAIに譲り続けて鈍らせることだ。私が書いているのは、後者の話。反証条件も書いておく。AIに丸ごと外注した人間が、自分で書く人間と同じ判断の精度を、長期で保てることが示されたら、私はこの線を引き直す。今のところ、それを示す証拠は、私の手元には届いていない。「思考はAIに、身体は自分に」と書いた。これは綺麗な対比に見える。だが、書いてみて、自分でも引っかかっている。思考も、丸ごとは外注できない。外注できるのは、ドラフト、要約、調査、整形——表層の労働だけだ。「なぜそうしているのか」「何を引き受けているのか」は、私の頭から外に出せない。出した瞬間、コードは動くが、判断ができない人間が残る。動いているコードを前にして、「なぜこれでいいのか」を答えられない。リリースの責任者が、判断の根拠を自分の中に持っていない。契約書に名前を書くのは、最後まで人間だ。AIは、責任の主体になれない。なれない以上、判断の根拠を自分の中に持たない人間に、契約書の重みは支えられない。書いていて気づいたが、これは身体の話と同じ構造をしている。契約書に名前を書くのも、自分の身体を抱えるのも、構造的に自分しかいない。AIにも、社会にも、外注できない領域。そこに残るものを、どう手入れするか、というだけの話だ。それで、今の私の運用はこうなっている。ドラフトはAIに書かせる。ただし、マージする前に、自分の手で写し直す日がある。コピペで済ませると、頭に残らない。手で打ち直すと、どこで自分が考えていなかったかが分かる。手を動かすことで初めて、頭の中に像が立ち上がる。プログラミングは、手を動かしながら理論を組み立てていく仕事だ、と私はずっと思っている。AIが書いてくれた答えを眺めるだけでは、その理論は立ち上がらない。本当は全部AIにやってほしい。手で打ち直すのは面倒だ。サボりたいAIユーザーとしての自分を、私は隠していない。隠してもしょうがない。ただ、サボったあとに、自分の中で何かが薄くなる感覚があって、それが嫌だから、面倒な側を選んでいる。好きで選んでいるのではなく、嫌な感覚を避けるために選んでいる。これも、たぶん、健康への投資と同じ動機だ。ここで、身体の話に戻ってくる。ジムでバーベルを担ぐのと、コードを自分の手で打ち直すのは、私の中で同じ構造をしている。外注したほうが速い。外注すると、その能力が鈍る。だから、意図的に、自分の身体を、自分の手と頭を、使い続ける。これは効率の話ではない。効率を取り過ぎると、効率を判断する力そのものが衰える、という、もっと一段下の話だ。身体が壊れたら、AIがどれだけ進化しても、私はそれを使う側にいられない。腰が立たなければ、椅子に座っていられない。集中力が続かなければ、AIが書いたコードを最後まで読めない。判断の解像度も、立ち続ける身体も、放っておけば減る。これがAI時代の専門性の上限を決めている。エンジニアとして「作れるかではなく、作るべきか」を判断するのが仕事だ、と思っている。技術選定でも、これを基準にしている。作れる。作れるが、これをプロダクション品質で検証し続けるのは、私たちの仕事ではない。同じ構造を、AIへの外注にも、身体への投資にも、同じように適用している。AIに書かせられる。書かせられるが、なぜそうしたかを引き受け続けるのは、私の仕事だ。ジムのマシンに任せられない。任せられないから、私の脚と背中で担ぐしかない。「思考はAIに、身体は自分に」と書いた。書いてみて、これだけでは足りないと気づいた。書き直すと、こうなる。ドラフトはAIに、引き受けは自分に。身体も自分に。三つを引き受け続けることが、AI時代の「俺らの抵抗」の、たぶん、中身だ。——と書いてみるが、本当だろうか。たぶん。5年後、10年後、私の判断さえもAIが代替できる場面は増えていく。それでも、AIが書いた答えを、自分の身体と頭で受け取り直す行為は、たぶん、長く残る。なぜなら、引き受ける主体は、AIにはなれないからだ。標準化されない領域に、価値が残る。「私が引き受けます」と言える人間と、自分の身体は、たぶん、最後まで標準化されない。仮に、AIが法的にも責任の主体になれる枠組みが整ったとしても、引き受けの一部は人間の側に残る、と私は見ている。引き受けは、外部化された制度の話だけではなく、自分の中に判断の根を持つ作業でもあるからだ。判断の根を外に置いた人間は、制度の側が変わった瞬間に、立っていられなくなる。これは法律の話ではなく、自分の足の話だ。逆向きも書いておきたい。身体が鈍れば、判断も鈍る。椅子から立たない日々が、AIのドラフトを判断する力そのものを薄くする。AI論と身体論は、片方向ではない。サルコペニアが認知負債に似ているのではなく、両方が同じ「使わなければ減る」という構造の上で動いている。思考はAIに、身体は自分に、と書いたあの一行は、半分だけ正しい。残りの半分は、身体が支える側の話だった。スマホには、振ると直前の操作を取り消すジェスチャーがある。Shake to Undo、と呼ばれている。技術には Undo があるが、人生には Undo がない。あるとすれば、毎朝、シェイカーを振ること、それくらいだ。昨日の不摂生も、サボった一回も、口にした言い訳も、朝に一度、振って取り消す。取り消した後で、もう一度、自分を大切な側に置き直す。シェイカーを振るのは、たぶん、私が自分のためだけに持っている唯一の Undo の身振りだ。変わったこと、深くなったこと足の話、と書いた。ここで一度、立ち止まる。ここまで身体とAIの話を書いてきた。書きながら、去年の今頃の自分と、今の自分の差分が、自分の中で少しずつ整理されてきた。変わったところと、変わらず深くなったところの両方がある。並べておく。変わったところを、まず書く。正しさだけでは仕事は通らない、と認めた。20代の私は、まだ「技術的に正しければ通る」と半分くらい信じていた。レビューでも会議でも、議論の筋を通せば、人は動くと思っていた。一年経って、そうではないと認めた。人は論理ではなく、信頼と物語で動く。ある会議で、設計の筋を一つひとつ説明したことがある。論理は通っていた、と自分では思っていた。会議が終わったあと、決まった結論は、私が言ったことの逆だった。後で別の場で「あのとき、実は誰も論理を聞いていなかった」と教えてもらった。誰が言ったか、いつ言ったか、どの場で言ったか——その3つのほうが、内容そのものより重く扱われていた。正しさは、置き場所を間違えると、ただの空気になる。これを続けて何度かやって、ようやく腹に落ちた。「おい、論理で人が動くと思ってるのか」と書いたのは、その時期の自分への命令文だった。20代の私だったら、たぶん書けなかった。実力とは「最悪の自分」が決める、と思うようになった。20代の私は、「ベストの自分」を基準に自分の能力を測っていた。バズった記事、評価された仕事、上振れした成果。今は逆だ。疲れているとき、機嫌が悪いとき、寝不足のとき、それでも淡々と納品できるアウトプットの下限——それが実力だ、と思うようになった。寝不足の日に書いたコードレビューを、後日読み返して、恥ずかしくなったことが何度かある。指摘の精度がはっきり落ちている。最悪の自分は、書いた本人にしか分からない形で漏れる。漏れた最悪の自分は、誰にも気づかれないまま、自分の評価の下限を、こっそり下げていく。ベストの自分が嘘だと言いたいわけではない。上振れた一回が、誰かの役に立つことはある。ただ、自分の現在地を測るとき、私はベストではなく床のほうを見るようになった。ベストではなく、最悪の側の値が、私の本当の値だ。健康に投資し始めた動機の半分は、たぶん、ここにある。寝ること、食べること、運動すること、部屋を掃除すること。全部、最悪の自分の床を上げる作業だ。次に、変わらず深くなったところを書く。「やるべきことを減らす」が「下限を上げる」に変わった。20代の私は「全部やろうとするな、戦略的にサボれ」と書いていた。これは今も同じだ。ただ、サボった時間で何をするか、の中身が変わった。サボった時間で、毎日同じ生活を回す。掃除する、寝る、食べる、運動する。退屈な反復こそが、最悪の自分の床を上げる、と気づいた。なぜ退屈な反復が床を上げるのか。最悪の自分は、特別な意思決定を要求された日に崩れる。崩れない自分を作るには、特別ではないことを、毎日同じ手順で繰り返すしかない。考えなくても手が動く、という状態を、毎日の積み上げで作っておく。「おい、部屋を掃除しろ」を書いた頃から、これは少しずつ自分の中でほどけてきた。言語化への信仰が、少しだけ崩れた。20代の私は、「言語化こそが力だ」と信じていた。「おい、言語化しろ」と書いた頃のテンションだ。今もそれは間違っていない。ただ、言語化できないものに価値が残ることにも気づいた。身体で覚えた感覚、現場の肌感、言葉にすると痩せてしまう種類の知識。これは、AIが要約しても、私が言語化しても、目減りする。言語化は翻訳だ。翻訳にはロスがある。引き受けた上で、それでも書く。孤独と隔絶を分けて考えるようになった。「中学17年生」では「未熟さを含めて自分を受け入れる」と書いた。今もそれは変わらない。ただ、孤独に見えたものの一部は、ただの隔絶だった、と気づいた。孤独は引き受けるしかない。隔絶は、ほどけるところからほどける。組織の中での通し方を学び始めたのは、隔絶のほうを少しでも減らすためだった。孤独まで減らす気はない。——一つだけ、変わったとも深くなったとも書きにくい話がある。強いて20代の振り返りに文句を言うとしたら、ここだ。大きな恋愛をして大きく裏切られると、強くなるわけではない。普通に、人間不信になるだけだ。よく「恋愛が人を強くする」と言うが、私の経験ではそうでもなかった。傷ついた分だけ、他人を疑うコストが上がった。それだけのことだった。これを「変わった」とも「深くなった」とも言いたくないので、リストの外に置いておく。並べてみて、気づいたことが一つある。変わったところも、深くなったところも、全部、自分の足元の話だ。20代の私は、もっと遠くを見ていた。「夢を持たなくてもいい」「専門性に情熱を注げ」と書いていた頃の私は、まだ未来を語っていた。31歳の私は、未来をあまり語らなくなった。代わりに、毎朝起きる時間と、毎食のタンパク質と、毎週担ぐバーベルの重さの話をしている。遠くではなく、足元。これは退化ではない、と自分には言い聞かせている。たぶん、そうではない。未来から目を背けたわけではない。未来は、足元の毎日にしかない、と気づいた。だから、足元が固まらない人間が、遠くまで歩けた試しがない。並べてみると、もう一つ気づく。変わったところも、深くなったところも、振り返ると、全部、一つの抵抗の地ならしだった。何に対する抵抗かは、この先で書く。抵抗する、ただし筋肉で足元の話まで来た。ここで、はじめにつけたタイトルに戻る。31歳、もちろん俺らは抵抗するで？筋肉で。抵抗、という言葉を選んだのは、これが受け身の話ではないことを書きたかったからだ。実は、地味なきっかけがあった。趣味でやっていたバレーボールで、長時間プレーすると膝に違和感を感じるようになった。痛めた、というほどではない。ただ、以前なら何も起きなかった時間で、何かが起き始めている。医者に止められたわけではない。健康診断で引っかかったわけでもない。完全に、年齢だった。それまで普通に動いていた身体が、普通に動いてくれなくなる入り口が、こういう地味な形で来る、ということを、その瞬間に知った。動けなくなる未来が、自分の身体の側から確実に近づいている——その感覚だった。健康のために運動する、というのは受け身の文脈で語られがちだ。「医者に言われたから」「健康診断で引っかかったから」。それも一つの動機ではあるが、私の動機はもう少し攻撃的だ。もう一つ、動機がある。私は今、自己啓発に類する本を出すことになっている。自己啓発本の著者が太っているの、本当に意味が分からなすぎる——というのが、私が以前から持っている違和感だった。本を出して「習慣を変えろ」「朝早く起きろ」「健康に投資しろ」と書いている人間が、自分の身体を放置している、という構造が、どうしても飲み込めない。書く側に立つなら、書いた言葉に、身体で責任を持つ。書いた言葉が嘘にならない身体でいたい。これも、抵抗の動機の半分くらいを占めている。何に抵抗しているのか。三つある。一つ目は、老化への抵抗。これは確定した未来だ。何もしなければ、筋肉は毎年わずかずつ減る。10年後、20年後、立ち上がるのに苦労する自分がいる。これは性格の問題ではなく、生物学の問題だ。確定した未来に対して、何もしないのは、選択ではない。何もしないという選択は、確定した負けを受け入れているだけだ。だから抵抗する。負けるとしても、負け方を変える。私の中で、筋肉は装飾ではなくなった。筋肉は、これから来る20年、30年に対して、自分が着る鎧だ。鎧は、戦闘のための服ではない。毎日普通に生活する人間が、転ばずに立ち続けるための服だ。20代までは、誰もが何も着ずに歩けた。30代から、私は、自分の鎧を、自分で編み始めた。ただ、強い言い方になっているのは自覚している。二つ目は、AI時代の身体性の喪失への抵抗。私たちの仕事は、椅子から立たない方向にどんどん最適化されている。AIが手伝ってくれることが増えれば増えるほど、私の手と足の出番は減っていく。気を抜けば、起きてから寝るまで、ほぼ動かないまま一日が終わる。これは、便利さの代償としてはあまりに大きい。身体は、使わなければ衰える方向に作られている。だから、意図的に使う。意図的に立つ、意図的に担ぐ、意図的に汗をかく。これが、AI時代の身体性に対する、私のささやかな抵抗だ。三つ目は、自分の怠惰への抵抗。これが一番、書きにくい話だ。私は、サボろうと思えばいくらでもサボれる。今もサボりたいと思いながら、ジムに行っている日が多い。「行きたいから行く」は、たぶん、5回中1回くらいだ。残りの4回は「行ったほうがいい気がするから行く」で、もっと正確に言えば「行かないと、来年も再来年も、もっと行きたくなくなるのを知っているから、今日行く」だ。今日のサボりは、未来のサボりを、確定的に引き寄せる。怠惰は、未来の自分から借金している。返すのも、利息を払うのも、未来の自分だ。これは経験則だ。理屈ではない。20代の頃、何度か健康に取り組もうとして、続かなかった。続かなかった一番の理由は、たぶん、「いつでも始められる」と思っていたからだ。20代の身体には余裕があったから、いつでも始められる、いつでも取り戻せる、と思えた。30歳に入って、その余裕が確実に減っていることに気づいた。いつでも始められる、は嘘だ。始められる時期に、始めなければ、始まらない。サボろうとするとき、人間は驚くほど似た形で言い訳を作る。私もだ。並べてみると、いくつかの型に収まる。型を見えるようにしておきたい。一つひとつは違って見えても、骨格が同じなら、潰し方も同じになるからだ。一つ目は、資源不足の語り。時間がない、金がない、体力がない、機材がない——足りないものを起点にして、動かないことを正当化する型だ。本体は配分の問題で、絶対量の問題ではない。動けなくなった身体を引き戻すために使う時間は、動ける身体を維持する時間とは桁が違う。毎日スマホを眺めている時間の何分の一かを、自分の身体に振り直す。資源は「ある」「ない」で語るものではない。「どこから取るか」で語るものだ。「足りない」と言うとき、私たちは大抵、配分のことを語っている。二つ目は、動機の外注。「誰も気にしない」「褒めてもらえない」「見られていない」——自分の動機を、他人の反応の側に置く型だ。半分は事実で、半分は嘘だ。確かに、私が今日ジムに行ったかを気にする人は、たぶんいない。ただ、はじめに書いた通り、自分の身体を最後のところで抱えるのは、構造的に自分しかいない。気にしない他人を基準にしている限り、動機は他人の側に預けられたままだ。気にしてくれる誰かを待っていても、その誰かは、たぶん来ない。三つ目は、結果の先回り。「どうせ失敗する」「自分には続かない」と、結果を確定させてから始めない型だ。これは、いちばん安全に見える言い訳だ。失敗を予言しておけば、傷つかなくて済む。20代の私は、これを何度かやった。ただ、その先回りは、続けなかった理由ではない。続けなかった結果を、続ける前に持ってきているだけだ。順番が逆だ。四つ目は、計画への責任転嫁。「非現実的だ」「無理な設計だ」と、降りる理由を計画の側に置く型だ。私が鶏胸肉を週の頭に茹でて小分け冷凍するところまでやっているのは、自分の意思の力が非現実的なほど弱いからだ。非現実的なのは、計画ではなく、私の意思のほうだった。計画は、その弱さに合わせて作り直す。降りる理由ではなく、設計を直す理由として扱う。五つ目は、例外性への賭け。「自分は体質的に違う」「遺伝的に減らない」「自分は特別だ」と、平均値ではなく外れ値の側に自分を置く型だ。事実、外れ値の人はいる。問題は、自分がその一人である根拠を、私たちは大抵持っていないことだ。自分が外れ値だと信じる側に賭けるのは、いちばん検証されていない賭けだ。前にも書いたが、自分が外れ値である可能性に賭ける勇気は、私にはなかった。六つ目は、準備の無限延期。「もう少し本を読んでから」「正しいやり方を学んでから」「もっと準備が整ってから」——着手を、準備の名の下に先送りする型だ。これは、いちばん知的に見える言い訳だ。前に書いた通り、本は刺激にはなるが骨格にはならない。骨格は、動き始めた身体の中にしか作られない。整ってから動こうと思っていた20代の私は、整える前に30歳になっていた。六つ並べてみると、共通点が一つある。今日の言い訳の利息は、未来の自分の体力で支払われる。借りるのは今日の自分で、返すのも利息を払うのも、未来の自分だ。これに気づいてから、私は自分の言い訳に、少しだけ厳しくなった。ここまで書いて、自分でも気づいたことがある。怠惰への抵抗の本体は、やる気ではなく、仕組みだった。やる気に頼っているうちは、私は確実にサボる。鶏胸肉が冷蔵庫にすぐ食べられる形で入っていなければ、ラーメンに行く。ジムが家から遠ければ、行かない。寝る時間を先に決めていなければ、コードを書き続ける。やる気を強くすることに賭けない。やる気が切れた日でも、勝手に身体が動く形を、先に作る。意思の力で続けようとした時期もあった。続かなかった。続いているのは、やる気が湧かない日でも、勝手に手と足が動く手順として、生活を組み立て直したからだ。仕組みは、やる気の代わりではない。やる気が切れた日に、それでも回るための、もう一段下の防衛線だ。仕組みのもう一段奥にあるのは、自分をどういう人間として扱うか、だと思っている。「私は鶏胸肉を切らさない人間だ」「私は寝る時間を最初に取る人間だ」——そう自分に対して決めると、毎日の選択は、その役どころに揃っていく。目標を達成するのではなく、その目標を達成する人間として、毎日現れる。これは、続けるための最後の足場のような話だ。ここまで書いて、もう一段奥にあるものを言葉にしておきたい。世界は、観察だ。同じ朝でも、見ている人によってまったく違う世界になる。階段を上って息が切れたとき、それを「歳を取った」と見るか、「今日も身体が動いている」と見るか。プロテインを飲み下すとき、「面倒な日課」と見るか、「自分への一杯」と見るか。世界そのものよりも先に、自分がどう見るかがある。筋肉とトレーニングの話は、突き詰めるとここに繋がっている。バーベルが上がるか上がらないかは、客観的な事実だ。ただ、上がった一回をどう数えるか、上がらなかった一回をどう数えるかは、自分にしかできない。3セット目で太腿が震えたあの夕方、「終わった」と数えることもできたし、「もう1回いける」と数えることもできた。身体は同じだ。観察が違うだけだ。毎日の食事も、毎日の睡眠も、毎日のジムも、結局は自分が世界をどう観察するかの訓練になる。「今日も鶏胸肉を切らさなかった」と数えるのは、自分にしかできない。誰も褒めてくれない。誰も気づかない。自分が自分の積み上げを観察しなければ、その積み上げは、存在しないのと同じだ。そして、この観察には、一発逆転がない。変化は徐々に起こる。観察が追いつくのは、常に少しあとだ。健康診断の数字が出る前から、筋肉のレベルでは進行している。20代の貯金が切れる前から、引き算は始まっている。リバウンドが始まる前から、生活はもう緩み始めている。気づいたときには、もう変わっている。気づかなかったから、変わらなかったわけではない。毎日自分を見ていない人間にとって、自分の身体は「気づいたら、もう遅かった」場所になる。毎日の0.01の積み上げが、一年後に違いとして現れる。その小さな違いを観察できる目を、自分の中に育てる。それだけだ。だから、毎日、たとえ気持ちが乗っていなくても、その日にやることを、その日にやる。今日頑張ることを、毎日やる。それしか、ない。私が一番きつい言葉として、自分に向けて書いておく。今日やれない人間は、明日もやれない。明日からやる、はだいたい嘘だ。今日やれない人間が、明日になれば急に動く、ということは、起きない。これは、私が自分自身に、何度も突きつけてきた言葉だ。そして、自分への言葉のつもりが、いつのまにか他人の話にも当てはまるようになっていた。仕事の幅が広がるなかで、いくつかの人の人生相談に乗る機会も増えた。色々な話を聞いた。それでも、最後に残るのは、毎回同じだった。結局は、今日だ。今日を頑張れない、今日を怠る人間に、明日は来ない。今日が積み上がった先に明日があるのであって、明日が先に置かれているわけではない。でも、それでも、はじめるには、今日しかない。自分にきつい言葉だからこそ、他人にぶつける気はない。ここまで書いて、自分の中で順番が一つはっきりした。マインドセット → 食事 → 運動。最初に運動から手を入れたくなる人が多いし、私もそうだった。だが、心の構え方が決まっていない運動は、長くは続かない。心の次に来るのは、運動ではなく食事だ。動くことより、食うことのほうが、生活の中で決断の回数が多いからだ。動きは最後でいい。動きは、整った心と食事の上に、自然と乗ってくる。仕組みも、観察も、今日の動作も、結局はこの順番のなかにある「自分への態度」の話だった。戦略的判断としての筋トレ、という言い方もできる。私は仕事で技術選定をするとき、「作れるか」ではなく「作るべきか」を判断軸にしている。同じ軸を、身体にも適用する。やれるかではなく、やるべきか。30代に入ったばかりの身体には、まだ「やれる」余地が残っている。それは事実だ。ただ、「やるべきか」という問いに対しては、答えは「やるべき」しかない。やれる時期にやらない人間が、やるべきタイミングでやれた試しがない。これも経験則だ。ただし、これは比較的自由に時間を使える側にいる人間として書いている。やれない事情を抱えている人にこれを突きつけたいわけではない。書いているのは、自分の予算配分を健康に振り直した、という話だ。ジムに行け、と説教したいわけではない。私が書きたいのは、同じことに気づいているかもしれない誰かに、言葉にしておきました、と渡すことだ。気づいていなくてもかまわない。読み流してくれてかまわない。戦略は、何をやらないかで決まるここまで「抵抗」と書いてきたものを、もう一段抽象化しておきたい。私が腹に落ちている戦略の定義は、こうだ。良い戦略は、目標でもビジョンでもない。困難な状況の診断、その診断に対する基本方針、そして方針に沿った首尾一貫した行動——この三つの組のことだ。逆に言うと、悪い戦略は、目標を戦略だと言い張る。「健康になる」「ムキムキになる」「来年は痩せる」——これは目標であって、戦略ではない。困難の中身を見ていない。方針もない。行動もばらばらだ。身体の側にこの枠組みを当てはめると、形がはっきりした。診断は、30代から筋肉の引き算が始まる、AI時代に身体性が削られる、自分は仕組みがないとサボる、の三つだ。ここまで散々書いてきた話の整理にすぎない。基本方針は、その引き算に対して、筋肉を、装飾ではなく中心の臓器として置き直す。行動は、毎日の鶏肉と卵、週に何度かのコンパウンドリフト、寝る時間を最初に取る、の三つに集中する。診断と、方針と、行動が、お互いに矛盾しないように組まれている。これを、私は戦略と呼んでいる。戦略の核は、何をやるかと同じくらい、何をやらないかで決まる。コードで言えば、書く行より、書かないと決めた行のほうが、設計を決めることがある。身体で言えば、やらないと決めたのは、見せるための種目、最新のサプリを全部買うこと、見た目重視のメニュー組み、健康診断の数字をその場で取り返す走り方。資源は、絞らない限り、決定的なところに届かない。30代の身体に届く資源は、たぶん、若い頃ほど潤沢ではない。だから、どこに寄せるかを、先に決める。前のほうで書いた SMART は、この戦略の「行動」を、追える粒度まで降ろした形にすぎない。診断・方針・行動の三層が決まっていなければ、SMART は数字遊びになる。順番がある話だった。鍛えたいのは筋肉だけではない筋肉の話ばかり書いてきた。ここで、筋トレとは別の話を書いておきたい。30代に入って、もう少し抽象的に「鍛えたいもの」を考えるようになった。人生の大半は、計画ではなく、偶然で動く。これは、私の経験則だ。動くたびに、その偶然を機会に変えられるかどうかが、たぶん、長い目では効いてくる。だとすれば、私が鍛えたいのは、偶然が来た瞬間に、それを引き受ける側に立てる属性だ。私の中では、それは三つに集約される。踏み出す力、続ける力、作り直す力。集中するのは、相変わらず筋肉のほうだ。その土台の上に、薄く重ねるのが、この三つだ。一つ目は踏み出す力。知らない側に一歩出す力だ。読んだことのない領域の本を開く、初対面の人に話しかける、自分が劣勢に見える場で発言する、書ける根拠より書けない根拠のほうが多い仕事を引き受ける。今の私が一番足りていないのは、たぶん、これだ。年齢が上がるほど、初手のコストは上がる。失う側のものが、少しずつ増えるからだ。20代の私は、失う前提で動けた。30代の私は、失わないことを考えてから動こうとする。考えてから動く順番に変わった瞬間、踏み出す回数は、確実に減る。これは20代の私のほうがうまくできていた、と認めるしかない。踏み出さない言い訳は、たいてい、頭の中で整っている。整っているから動かない。整わない状態で動いた回数を、私は、踏み出した回数として数えるようにした。二つ目は続ける力。結果が出るまでの時間を、決めたとおりに歩く力だ。筋肉の話と地続きにある。今日の動作が、明日の数字には出ない。3ヶ月後の数字にも、たぶん出ない。半年、1年単位で、初めて影が動く。その間、見えない時間を、サボらずに歩く力。ここで要るのは、「いつかは出る」と思える側の燃料だ。これがないと、続けることは早晩、苦行になる。続ける力の半分は、たぶん、出る側に賭ける、という小さな信仰でできている。もう半分は、誰も見ていない朝に決めた通りにやる、という自分との約束だ。これは、意思の力ではなく、仕組みの力だ、と前にも書いた。規律の本体は、自分への態度のほうにある。決めた人間として、毎日現れるか。それだけの話だ。続ける力の本体は、たぶん、ここだ。小さな努力の積み重ねが、大きな結果を生む。人生はそれしかない。短く書くと根性論に聞こえるが、これは観察の結論だ。一発逆転がない、と前のほうでも書いた。逆転がないなら、積み上げしか残らない。もう一つ、書いておきたい。小さくても厳しい積み重ねが、実力を保証する唯一の方法だ。そして、たぶん、これは自分を好きになる方法でもある。実力は、ベストの自分ではなく、最悪の自分の床のほうで決まる、と前のほうで書いた。床を上げるのは、小さくて、厳しくて、誰も褒めない積み重ねだ。誰も褒めない。ただ、自分は知っている。誰にも見られていない朝にやったことを、自分は覚えている。覚えている自分の数が、たぶん、自分を好きになる量を、こっそり決めている。三つ目は作り直す力。状況が変わったり、自分が折れたりしたあとに、形を作り直す力だ。作り直す対象は二つある。計画の側と、自分の側。両方が要る。計画の側のほうは、執着と区別がつきにくいのが難しい。頑固さは、作り直す力の対義語ではない。執着のほうが対義語だ。残すべきものに頑固で、捨てるべきものに執着しない。仕事でも、技術選定でも、身体でも、これは同じ形をしている。自分の側のほうは、折れないことではない。折れる前提で、立ち上がる手順を、先に持っておくこと。30代で、私は仕事で一度、関係で一度、身体で一度、それぞれ別の形で折れた。折れたあとに気づいたのは、折れる前に手順を持っていなかった自分だ。今は、折れる前提で、手順を先に書いておくようにしている。折れない人間ではなく、折れた翌朝に一度自分を取り消して、もう一度組み直せる人間で在りたい。前のほうで書いた Shake to Undo の身振りを、自分の身体に対しても持っておきたい、という話だ。三つ並べてみると、筋トレと全部地続きにある。毎日同じ時間に重いものを担ぐ、という単純な動作の中で、この三つは薄く、同時に鍛えられている。気づいてしまえば、当然の話だ。ただ、気づくまで、私はジムを「身体だけのための場所」だと思っていた。筋肉は、土台ではある。ただ、その土台の上に乗せたいのは、この三つだ。筋トレだけのためではなく、30代で、私がやり抜きたい話は、ここにある。来年の自分に、また書く朝のシェイカーの話に戻る。プロテインを飲み干して、ノートPCに向かう。AIが用意してくれていたコードレビューの差分に、今日の私が判断を入れていく。机の横には、昨夜寝る前に飲んだギリシャヨーグルトの空き容器がある。それを片付けて、今日の一日が始まる。去年の今頃、30歳の扉の前でうろたえていた私に、今の私から言いたいことがあるとしたら、ドラフトはAIに、引き受けは自分に。身体は、誰にも譲れない。譲っていいものと、譲ってはいけないもの。30歳の一年で、私が掴んだのは、その線引きだった。線の引き方は、人によって違うかもしれない。「筋肉が全て」という言い回しに、最初に出会ったときは、極端だと思った。今は、そうでもないと感じる。筋肉だけが全てではない。それでも、筋肉がない人間に、他の全てが乗ってこない。順番の話だった。私の中で、その言い回しは少し直された。全てではない。ただ、土台ではある。筋肉が全て: 健康・不老・メンタル、人生のすべてが変わる唯一の方法作者:ガブリエル・ライオンダイヤモンド社Amazon私が選んだ抵抗の形が、これだった。あなたの抵抗の形は、たぶん、別のところにある。もちろん、俺らは抵抗するで？筋肉で。「俺ら」に、これを読んでくれているあなたを、勝手に入れさせてもらった。迷惑だったら抜けてもらってかまわない。私は来年の今頃、また何か書く。続いていれば、それを書く。続かなかったら、続かなかった理由を書く。31歳は、まだ重い荷物を運べる年齢だ。運べなくなる前に、運べる重さを増やしておく。それだけのことを、毎日やっている。来年の誕生日の朝も、通知はたぶん、それほど鳴らない。母親からは、メッセージが来るだろう。明日もまた、一度取り消す。取り消して、自分を大切にする側に置き直す。シェイカーは、もう乾いている。明日の朝、また振る。おい、自分で守るって言ったろうが——と、自分でも先に突っ込んでおく。ここまで散々「抵抗」だの「世話」だのと書いてきて、最後の最後に欲しいものリストを置く図々しさ。それでも、何か祝ってくれる人がいたら、社会との繋がりを実感できて、助かります。nwiizo の欲しいものリストwww.amazon.jp]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[コンパイル時に未設定フィールドを禁止する type-state builder]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/10/095323</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/10/095323</guid>
            <pubDate>Sun, 10 May 2026 00:53:23 GMT</pubDate>
            <content:encoded><![CDATA[はじめにBuilder パターンで「url を設定する前に build() を呼んでしまった」事故を、 コンパイルエラーで止める 書き方があります。type-state pattern と呼ばれています。PhantomData を使って builder の進捗状態を型に乗せる、 Rust 固有の idiom です。ランタイムオーバーヘッドはゼロ。PhantomData<T> はサイズ 0 で、コンパイル時にしか存在しません。まず動くコードuse std::marker::PhantomData;#[derive(Debug)]pub struct Missing;#[derive(Debug)]pub struct Set;#[derive(Debug)]pub struct Request {    pub url: String,    pub body: String,}#[derive(Debug, Default)]#[must_use = "Builder は最後に build() を呼んで Request を取り出す"]pub struct RequestBuilder<UrlState, BodyState> {    url: Option<String>,    body: Option<String>,    _state: PhantomData<(UrlState, BodyState)>,}impl RequestBuilder<Missing, Missing> {    pub const fn new() -> Self {        Self {            url: None,            body: None,            _state: PhantomData,        }    }}impl<B> RequestBuilder<Missing, B> {    pub fn url(self, url: impl Into<String>) -> RequestBuilder<Set, B> {        RequestBuilder {            url: Some(url.into()),            body: self.body,            _state: PhantomData,        }    }}impl<U> RequestBuilder<U, Missing> {    pub fn body(self, body: impl Into<String>) -> RequestBuilder<U, Set> {        RequestBuilder {            url: self.url,            body: Some(body.into()),            _state: PhantomData,        }    }}impl RequestBuilder<Set, Set> {    pub fn build(self) -> Request {        Request {            url: self.url.expect("type-state guarantees url is Some"),            body: self.body.expect("type-state guarantees body is Some"),        }    }}fn main() {    let req = RequestBuilder::new()        .url("https://example.com")        .body("payload")        .build();    println!("{req:?}");}cargo run するとこう出ます。Request { url: "https://example.com", body: "payload" }注目すべきは、最後の build() を呼べるのが RequestBuilder<Set, Set> の場合だけ ということです。なぜコンパイルエラーで止まるのか以下のように url を呼ばずに build() するコードを書くと、コンパイルエラーになります。// ↓ これはコンパイルが通らないlet req = RequestBuilder::new().build();エラーはこうなります。error[E0599]: no method named `build` found for struct              `RequestBuilder<Missing, Missing>` in the current scope   |   |     let req = RequestBuilder::new().build();   |                                     ^^^^^ method not found   |note: the method `build` is implemented on `RequestBuilder<Set, Set>`build() は RequestBuilder<Set, Set> にしか実装されていないので、Missing 状態の builder からは呼べません。実行する前にコンパイラが弾きます。どう動いているか仕組みは3層です。状態を表す型を定義する: Missing と Set という空の structbuilder にこれらの型を generic で持たせる: RequestBuilder<UrlState, BodyState>メソッドが状態を変える: url() は Missing → Set に状態を進めるurl() の戻り値が RequestBuilder<Set, B> になっているので、呼び出すたびに型が変わります。連鎖呼び出しの形を見ます。RequestBuilder<Missing, Missing>     ← new()    .url(...) →RequestBuilder<Set, Missing>    .body(...) →RequestBuilder<Set, Set>    .build()最後の Set, Set でだけ build() が呼べる。 これがコンパイル時の「未設定検出」です。PhantomData の役割_state: PhantomData<(UrlState, BodyState)> は、generic パラメータ2つを struct で「使っている」ことにするためのフィールドです。Rust は generic パラメータを実際に使っていないと「unused type parameter」と怒ります。PhantomData<T> はサイズ 0、ランタイムには何も持ちませんが、型システム上は T を使っていることになります。これで generic パラメータが struct に「乗ります」。PhantomData<(UrlState, BodyState)> のようにタプルにすると、複数の型パラメータをまとめて持てます。どの順で呼ばれてもよい上のコードは url → body の順でも body → url の順でも動きます。それぞれの状態に対して個別に impl ブロックを書いているからです。// どちらでも OKRequestBuilder::new().url("u").body("b").build();RequestBuilder::new().body("b").url("u").build();// 同じメソッドを2回呼ぼうとすると型エラー// .url(...).url(...)  ← Set 状態には url() の impl が無い「同じメソッドを二度呼ぶのを防ぐ」もコンパイル時に保証されます。デフォルト値を許す場合「body は省略可能で、省略時は空文字」みたいな仕様を入れたい場合、default を持つ Set 風の状態を別途用意します。impl<U> RequestBuilder<U, Missing> {    /// body は明示的に省略する場合、`Set` 状態に進める。    pub fn no_body(self) -> RequestBuilder<U, Set> {        RequestBuilder {            url: self.url,            body: Some(String::new()),            _state: PhantomData,        }    }}build() の前に body() か no_body() のどちらかを必ず呼ぶ必要がある、という設計になります。制限と代替案type-state builder には欠点もあります。コードが長い: 単純な builder より3〜4倍ぐらいに増えるジェネリックパラメータが増える: 状態を3〜4個持つと RequestBuilder<S1, S2, S3, S4> の形になり、エラーメッセージも長くなる動的に builder を持ち回せない: 状態を型に乗せるので、 Vec<RequestBuilder<...>> に詰めるのは現実的でないこれらが嫌な場合の代替を挙げます。必須フィールドはコンストラクタ引数で受ける: Request::new(url) のあとで .with_body(...) で optional を設定する。これでも url 未設定は防げるランタイム validation: build() で Result<Request, BuildError> を返す。テストで担保するderive_builder クレート: マクロで builder を自動生成する。type-state までは行かないが書く量は減る「絶対に未設定で build() させたくない」公開 API、特に外部開発者が触る SDK では type-state を使う価値があります。内部でしか使わない builder なら、ランタイム validation で十分なこともあります。#[must_use] を忘れずにbuilder には #[must_use = "理由"] を付けると、捨てたときに警告が出ます。#[must_use = "Builder は最後に build() を呼んで Request を取り出す"]pub struct RequestBuilder<UrlState, BodyState> { ... }「builder を作ったまま使わない」コードに気付ける小技です。まとめtype-state pattern は PhantomData<T> で builder の進捗状態を型に乗せるMissing / Set のような空 struct を使い、メソッド呼び出しで状態を遷移させるbuild() は完成状態 (Set, Set) でしか呼べないので、未設定の時はコンパイルエラーランタイムオーバーヘッドはゼロ短所は記述量。オープンな SDK 向きで、内部 builder には過剰な場合もある関連std::marker::PhantomData: https://doc.rust-lang.org/std/marker/struct.PhantomData.htmltype-state pattern について: https://cliffle.com/blog/rust-typestate/]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[自分のクレート外の型にメソッドを生やす — extension trait と sealed pattern]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/09/210159</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/09/210159</guid>
            <pubDate>Sat, 09 May 2026 12:01:59 GMT</pubDate>
            <content:encoded><![CDATA[はじめに「String に独自メソッドを追加したい」「外部クレートの型に1メソッドだけ生やしたい」場面があります。Rust の orphan rule のせいで、外部クレートの型に外部クレートのトレイトを直接実装するのは無理です。代わりに 自分のトレイトを定義して、その型に対する impl を書く のが extension trait パターンです。doc.rust-lang.orgさらに sealed trait pattern を組み合わせると、「このトレイトは外部から実装できない」ことを保証できます。公開 API の進化を破壊的変更にしないための小技です。まず動くコードString や &str に「指定文字数で切り詰めて末尾に … を付ける」メソッドを生やす例。mod sealed {    pub trait Sealed {}}pub trait StrExt: sealed::Sealed {    fn truncate_with_ellipsis(&self, max_chars: usize) -> String;}// 全ての &str / String / Cow<str> 等に対して一気に sealed トレイトを実装するimpl<T: AsRef<str>> sealed::Sealed for T {}// 同じ blanket で extension trait の本体も実装impl<T: AsRef<str>> StrExt for T {    fn truncate_with_ellipsis(&self, max_chars: usize) -> String {        let s = self.as_ref();        if max_chars == 0 {            return String::new();        }        let count = s.chars().count();        if count <= max_chars {            return s.to_owned();        }        let mut out: String = s.chars().take(max_chars - 1).collect();        out.push('…');        out    }}fn main() {    let title = "rust 2024 edition で書く idiomatic な話";    println!("{}", title.truncate_with_ellipsis(10));    let owned = String::from("hello world");    println!("{}", owned.truncate_with_ellipsis(20));}実行結果は次のようになります。rust 2024…hello worldString でも &str でも、自分が定義したわけではない型に .truncate_with_ellipsis(...) というメソッドが生えました。extension trait の構造extension trait のレシピは3ステップです。公開トレイトを定義する: pub trait StrExt { fn ...; }対象の型に impl する: 単一の型 (impl StrExt for String) でも、blanket impl (impl<T: AsRef<str>> StrExt for T) でも利用側で use する: use crate::StrExt; を書くと型にメソッドが現れる利用側はこう使います。use my_crate::StrExt;fn main() {    let s = "hello";    println!("{}", s.truncate_with_ellipsis(3));}メソッド名前空間が trait に紐付くので、use を書かないとメソッドが見えません。これが意図しない名前衝突を防ぐ仕組みです。blanket impl で対象を広げるimpl<T: AsRef<str>> StrExt for T のように generic で書くと、 AsRef<str> を実装する すべての型 が StrExt を持ちます。これに含まれるのは次のとおりです。&str (固定)String (AsRef<str> を実装)Box<str>Cow<'_, str>自作の newtype (pub struct Title(String) で AsRef<str> を実装すれば自動)利用者は意識せずに extension trait の恩恵を受けられます。sealed trait pattern とはextension trait の問題点として、 外部の利用者が自分の型に impl を書ける ことがあります。これは普通良いことなのですが、ときに困ります。たとえば次のような場面です。「このトレイトは将来メソッドを追加する予定」: 利用者が impl していると追加が破壊的変更になる「内部実装の詳細を漏らしたくない」: 利用者の impl で意図しない使われ方をされるsealed trait pattern を使うと、 そのクレート外からはトレイトを impl できない ようにできます。仕組みは「private モジュールに Sealed トレイトを置いて、自分のトレイトの super trait にする」というものです。mod sealed {    // pub だが、parent module 経由でしか参照できない private モジュールにある    pub trait Sealed {}}pub trait StrExt: sealed::Sealed {  // sealed::Sealed が super trait    fn truncate_with_ellipsis(&self, max_chars: usize) -> String;}// 自分のクレート内でだけ Sealed を impl できるimpl<T: AsRef<str>> sealed::Sealed for T {}impl<T: AsRef<str>> StrExt for T { ... }外部の利用者は sealed::Sealed にアクセスできません。モジュール sealed を pub にしていないので外から見えないからです。そのため StrExt を impl しようとしても super trait Sealed が満たせません。これにより、次の2つが得られます。メソッド追加が破壊的変更にならない (利用者は impl していないので)自分のクレートだけが「StrExt を実装する型を増やす」権限を持つ公開クレートで extension trait を出すなら、sealed にしておくのが基本姿勢です。いつ extension trait を使うか判断目安を表にしておきます。 使うべき  使わないべき  標準ライブラリ型に1〜2メソッド足す  自前の型に普通のメソッドを足す (素直に impl で OK)  外部クレートの型に独自処理を生やす  全く同じ機能のフリー関数で済む  利用者が use を書いて opt-in する設計  コア機能で常に有効にしたい 「フリー関数 (fn truncate_with_ellipsis(s: &str, ...)) で済まないだろうか」を一度自問するのが良いです。s.truncate(...) のメソッド呼び出しスタイルが UX として明確に勝る場面でだけ extension trait の価値があります。orphan rule との関係ここで「なぜ extension trait が必要か」を一度整理します。Rust の orphan rule は「トレイト or 型のどちらかが自分のクレートに属していなければ impl を書けない」というルールです。これにより次のような制約が生まれます。impl Display for MyType (自分の型) → OKimpl MyTrait for String (自分のトレイト) → OKimpl Display for String (どちらも他人) → コンパイルエラー「外部の String に外部の Serialize を impl する」みたいなことはできません。これは依存関係グラフが入り組んだときの impl 衝突を防ぐための設計です。extension trait は「自分のトレイトを定義して、それを外部の型に impl する」ことで、 orphan rule の制約内で「外部の型に機能を足す」を実現するパターンです。まとめextension trait は「外部の型に自分の機能を足す」 ためのトレイトレシピ: トレイト定義 → 対象型に impl → 利用側で useblanket impl で String / &str / Cow などに一括展開できるsealed pattern で「外部クレートからは impl できない」を保証できる公開クレートの extension trait は基本 sealed にする「フリー関数で済むだろうか」を自問してから extension trait を選ぶ関連Rust API Guidelines / sealed trait: https://rust-lang.github.io/api-guidelines/future-proofing.htmlorphan rule: https://doc.rust-lang.org/reference/items/implementations.html#trait-implementation-coherence]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[ADKでGemini Live APIを使う時のハマりどころ3選]]></title>
            <link>https://zenn.dev/kimitsu/articles/adk-gemini-live-api-tips</link>
            <guid isPermaLink="false">https://zenn.dev/kimitsu/articles/adk-gemini-live-api-tips</guid>
            <pubDate>Fri, 08 May 2026 22:54:16 GMT</pubDate>
            <content:encoded><![CDATA[最近Agent Development Kit（ADK）でLive APIを使う機会があり、ハマりどころがいくつかあったので共有です。なお内容は記事執筆時点の情報になります。執筆日：2026/05/09Python：3.14ADK：1.32.0 1. 3.1 Flash Liveを使う場合はGemini API経由にするADKに限った話ではないのですが、3.1 Flash Liveは現時点ではAgent Platform（旧Vertex AI）では提供されていません[1]。Gemini APIでは提供されているのでこちらを使います[2]。基準は分かりませんが他にもこ...]]></content:encoded>
            <author>Yunosuke Yamada</author>
        </item>
        <item>
            <title><![CDATA[`lazy_static!` はもういらない — `LazyLock` と `OnceLock` の使い分け]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/08/085402</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/08/085402</guid>
            <pubDate>Thu, 07 May 2026 23:54:02 GMT</pubDate>
            <content:encoded><![CDATA[はじめにRust で「グローバル変数を遅延初期化したい」となったとき、長らく lazy_static! クレートが定番でした。今 (2026年) はもう不要です。標準ライブラリの LazyLock (1.80+) と OnceLock (1.70+) で全部書けます。マクロや外部依存も要りません。ただし MSRV (最小サポート Rust バージョン) を 1.80 未満で固定しているプロジェクトは、当面 lazy_static! のままでよいです。本記事は新規プロジェクトと、MSRV を引き上げる余地のあるコードベースを対象にしています。両者は似ていますが用途が違います。この記事ではどっちを使えばよいかを、実例つきで整理します。lazy_static! で書いていたコードこれまで lazy_static! で書いていた典型的なコードを置きます。// 古いコード (lazy_static = "1")use lazy_static::lazy_static;use regex::Regex;use std::collections::HashMap;lazy_static! {    static ref EMAIL_REGEX: Regex =        Regex::new(r"^[\w.+-]+@[\w.-]+\.[\w-]+$").unwrap();    static ref DEFAULT_HEADERS: HashMap<&'static str, &'static str> = {        let mut m = HashMap::new();        m.insert("Content-Type", "application/json");        m.insert("X-Version", "1");        m    };}fn is_email(s: &str) -> bool {    EMAIL_REGEX.is_match(s)}これはこれで動きますが、 lazy_static! クレートを依存に入れる必要があり、マクロ独自の構文を覚える必要があります。LazyLock で同じことを書くLazyLock<T> は std にある「初回アクセス時にクロージャを実行して T を生成し、以降はキャッシュする」型です。スレッド安全で、Sync です。use std::collections::HashMap;use std::sync::LazyLock;use regex::Regex;static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {    Regex::new(r"^[\w.+-]+@[\w.-]+\.[\w-]+$").expect("invalid regex literal")});static DEFAULT_HEADERS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {    HashMap::from([        ("Content-Type", "application/json"),        ("X-Version", "1"),    ])});fn is_email(s: &str) -> bool {    EMAIL_REGEX.is_match(s)}差分は地味ですが、次のような違いがあります。lazy_static! マクロ不要 (std だけ)static キーワードに加えて型が見える (LazyLock<Regex>)初期化クロージャが普通の Rust 構文特に 「型が static シグネチャに見える」 のが嬉しいポイントです。lazy_static! だと初期化式から型を推測する必要がありました。LazyLock から値を取り出すときは Deref 経由で透過的にアクセスできます。EMAIL_REGEX.is_match(...) のような呼び出しがそのまま書けます。明示的に取り出したいときは &*EMAIL_REGEX か LazyLock::force(&EMAIL_REGEX) でも取得できます。OnceLock は何が違うかOnceLock<T> も似ていますが、初期化のタイミングが違います。LazyLock<T>: 宣言時にクロージャを与える。初回アクセスで実行される。OnceLock<T>: 宣言時には空っぽ。外から set() で1回だけ値を入れる。つまり「初期化ロジックを宣言と一緒に書ける」なら LazyLock、「動的に値を流し込みたい」なら OnceLock です。OnceLock の典型的な例: アプリ起動時にコマンドライン引数や環境変数から構築した設定を、グローバルとして保持する。use std::sync::OnceLock;#[derive(Debug)]pub struct Config {    pub api_key: String,    pub max_retries: u32,}static CONFIG: OnceLock<Config> = OnceLock::new();pub fn install_config(c: Config) -> Result<(), Config> {    CONFIG.set(c) // 2回目の set は Err を返す}pub fn config() -> &'static Config {    CONFIG.get().expect("CONFIG not initialized")}fn main() {    let _ = install_config(Config {        api_key: std::env::var("API_KEY").unwrap_or_default(),        max_retries: 3,    });    println!("max_retries = {}", config().max_retries);}install_config を main の冒頭で1回呼ぶ。あとは config() でアクセスする。使い分けの判定フロー迷ったら3問の判断フローです。値が コンパイル時に決まっている だろうかはい → const か staticいいえ → 次へ初期化に 副作用や外部入力 が必要だろうかはい → OnceLock (set を呼ぶ)いいえ → LazyLock (クロージャを宣言時に書く)例を挙げます。正規表現リテラル → LazyLock (Regex::new はパースだけで副作用なし)HashMap で固定キーバリュー → LazyLockアプリ起動時に CLI 引数を保持 → OnceLockDB コネクションプールへのハンドル → OnceLock (起動時に接続)デフォルト値とのフォールバックOnceLock で「set されていなければデフォルトを返す」を書きたいなら LazyLock と組み合わせます。use std::sync::{LazyLock, OnceLock};#[derive(Debug)]pub struct Config {    pub greeting: String,}static DEFAULT_CONFIG: LazyLock<Config> = LazyLock::new(|| Config {    greeting: "hello".into(),});static RUNTIME_CONFIG: OnceLock<Config> = OnceLock::new();pub fn current() -> &'static Config {    RUNTIME_CONFIG.get().unwrap_or(&DEFAULT_CONFIG)}fn main() {    println!("before: {}", current().greeting);    let _ = RUNTIME_CONFIG.set(Config { greeting: "yo".into() });    println!("after:  {}", current().greeting);}実行結果は次のようになります。before: helloafter:  yoRUNTIME_CONFIG が空のあいだは DEFAULT_CONFIG を返し、set されたら以降そちらを返す、というパターン。設定の上書き機構を std だけで書けます。マルチスレッド安全性両方とも Sync を満たすので、スレッド間で共有できます。LazyLock: 初期化クロージャは複数スレッドが同時にアクセスしても1回だけ実行される (内部で同期される)OnceLock: set() と get() の両方がスレッド安全。set() の成功は1回だけ並行 set() 競合が気になる時は OnceLock::get_or_init(|| ...) が使えます。 LazyLock 風に「未設定なら初期化」を atomic に行います。use std::sync::OnceLock;static SLOT: OnceLock<u32> = OnceLock::new();fn main() {    let v = SLOT.get_or_init(|| {        println!("computing...");        42    });    println!("v = {v}");    // 2回目: 計算しない    let v2 = SLOT.get_or_init(|| {        println!("would compute again");        99    });    println!("v2 = {v2}");}実行結果は次のとおりです。computing...v = 42v2 = 422回目の get_or_init ではクロージャは実行されません。まとめlazy_static! クレートはもう不要かも⋯。std の LazyLock<T> / OnceLock<T> で書けるLazyLock: 宣言時にクロージャで初期化方法を決める。動的な入力が要らない場合OnceLock: 外から set() で1回だけ値を入れる。アプリ起動時の動的設定など両方ともスレッド安全フォールバックパターンは OnceLock + LazyLock の組み合わせで書ける依存を1つ減らせるのは地味ですが、新規プロジェクトでは何の理由もなく lazy_static! を持ち込まないでください。関連std::sync::LazyLock: https://doc.rust-lang.org/std/sync/struct.LazyLock.htmlstd::sync::OnceLock: https://doc.rust-lang.org/std/sync/struct.OnceLock.html]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[`Cow<'_, T>` で「変更が必要なときだけ allocate する」]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/07/121334</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/07/121334</guid>
            <pubDate>Thu, 07 May 2026 03:13:34 GMT</pubDate>
            <content:encoded><![CDATA[はじめに入力をそのまま返すか、一部書き換えてから返すか、関数の中で判定する場面があります。素直に書くと「変更しない場合も無駄に String::clone() してしまう」、または「&str を返したいが、変更があった時は所有データを返したい」というジレンマに陥ります。std::borrow::Cow<'_, T> (Clone-On-Write) はこのジレンマを解きます。借用 (Borrowed) と所有 (Owned) のどちらかを選んで返せる enum で、「必要になるまで allocate しない」が型で表現できます。doc.rust-lang.orgまず動くコード「すでに全部大文字ならそのまま借用を返し、小文字を含む場合だけ大文字化した所有データを返す」関数。use std::borrow::Cow;fn ensure_uppercase(input: &str) -> Cow<'_, str> {    if input.chars().all(|c| !c.is_lowercase()) {        Cow::Borrowed(input)              // 変更不要 → 借用のまま返す    } else {        Cow::Owned(input.to_uppercase())  // 変更必要 → 所有データを作って返す    }}fn main() {    let a = ensure_uppercase("HELLO");    let b = ensure_uppercase("hello");    println!("a = {a}, borrowed? {}", matches!(a, Cow::Borrowed(_)));    println!("b = {b}, borrowed? {}", matches!(b, Cow::Borrowed(_)));}実行結果は次のようになります。a = HELLO, borrowed? trueb = HELLO, borrowed? falsea は元の "HELLO" をそのまま借りています (allocate なし)。b は "hello" を大文字化した新しい String を持っています (allocate あり)。同じ "HELLO" という文字列でも、片方は借用、片方は所有。Cow がこの違いを型ひとつで表現できるのがポイントです。Cow の中身Cow の定義はこうです (簡略化)。pub enum Cow<'a, B: ?Sized + ToOwned + 'a> {    Borrowed(&'a B),    Owned(<B as ToOwned>::Owned),}B は借用形態 (str, [T], ...) 、<B as ToOwned>::Owned がそれを所有する型 (String, Vec<T>) です。Cow<'a, str> なら Borrowed(&'a str) または Owned(String)。利用者は Cow を Deref 経由で透過的に扱えます。&str が必要なメソッドはそのまま呼べます。let s = ensure_uppercase("Hello");println!("len = {}", s.len());        // どちらの variant でも動くprintln!("upper = {}", s.to_uppercase()); // どちらでも動く「allocate されているかどうか」を呼び出し側が気にする必要はありません。to_mut で「必要になったら所有に切り替える」Cow::to_mut(&mut self) を呼ぶと、Borrowed 状態だった場合だけ所有データに切り替えて、&mut Owned を返します。use std::borrow::Cow;fn append_exclamation(input: &str) -> Cow<'_, str> {    if input.ends_with('!') {        Cow::Borrowed(input)    } else {        let mut owned = Cow::Owned(input.to_owned());        owned.to_mut().push('!');        owned    }}fn main() {    println!("{}", append_exclamation("hi"));   // hi!    println!("{}", append_exclamation("yo!"));  // yo!}これは「すでに所有しているなら borrow から脱出して直接書き換える」のではなく、「Borrowed の場合だけ所有版へ変換し書き換え可能にする」という挙動です。関数の戻り値型として使うCow の真価は 戻り値型 で出ます。「入力をそのまま返したい場合もあれば、変更を加えた所有版を返したい場合もある」関数。use std::borrow::Cow;fn resolve_greeting<'a>(default: &'a str, override_with: Option<&str>) -> Cow<'a, str> {    override_with.map_or(Cow::Borrowed(default), |s| Cow::Owned(s.to_owned()))}fn main() {    let g1 = resolve_greeting("hi", None);    let g2 = resolve_greeting("hi", Some("yo"));    println!("g1 = {g1}, borrowed? {}", matches!(g1, Cow::Borrowed(_)));    println!("g2 = {g2}, borrowed? {}", matches!(g2, Cow::Borrowed(_)));}設定に上書きが指定されていなければ default をそのまま借用、指定されていれば所有データを作る。「上書きされなかった99%のケースで allocate が起きない」 設計が可能になります。いつ Cow を使うか入力データの大半をそのまま使いたいが、まれに変更が必要 な場面で効きます。具体例を挙げます。HTML エスケープ: 危険文字を含まなければ借用のまま、含めばエスケープ後の所有データを返すパスの正規化: すでに正規化されているならそのまま、必要なら正規化後を返す設定の上書き: 上書きされていなければ default を借用、上書きされたら所有テンプレート展開: プレースホルダがなければ借用、あれば展開後の所有データを返す逆に「常に変更する」ような関数では、最初から String を返すほうが素直です。Cow は 「変更しないことが多数派」 な場面で意味があります。Cow<'_, [T]> も同じ仕組みCow は文字列以外でも使えます。Cow<'_, [T]> で「Vec の借用 / 所有」を切り替えられます。use std::borrow::Cow;fn ensure_sorted(input: &[i32]) -> Cow<'_, [i32]> {    if input.windows(2).all(|w| w[0] <= w[1]) {        Cow::Borrowed(input)    } else {        let mut owned = input.to_owned();        owned.sort_unstable();        Cow::Owned(owned)    }}fn main() {    let a = ensure_sorted(&[1, 2, 3]);    let b = ensure_sorted(&[3, 1, 2]);    println!("{a:?}, borrowed? {}", matches!(a, Cow::Borrowed(_)));    println!("{b:?}, borrowed? {}", matches!(b, Cow::Borrowed(_)));}すでに昇順なら借用のまま、そうでなければソートして所有版を返す。まとめCow<'_, T> は Borrowed(&T) と Owned(<T as ToOwned>::Owned) の enum「変更が必要なときだけ allocate する」を型で表現できる戻り値型として使うと「変更しないことが多数派」な関数で allocate を回避できるto_mut() で必要になったタイミングで所有版に切り替えられる&str / &[T] どちらでも同じ仕組みCow を使うべきか迷ったら、 「この関数の戻り値、入力をそのまま返したいケースが半分以上あるだろうか」を考えてみてください。Yes なら Cow の出番です。関連std::borrow::Cow: https://doc.rust-lang.org/std/borrow/enum.Cow.htmlstd::borrow::ToOwned: https://doc.rust-lang.org/std/borrow/trait.ToOwned.html]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[newtype と `PhantomData` で「混ぜたら型エラー」を作る]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/06/111448</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/06/111448</guid>
            <pubDate>Wed, 06 May 2026 02:14:48 GMT</pubDate>
            <content:encoded><![CDATA[はじめにUserId と OrderId を取り違えてバグを生み出す経験は、String で識別子を扱っていれば誰しもあります。Rust では newtype で別の型に分けるだけで、コンパイラに止めてもらえます。さらに PhantomData を組み合わせれば、「JPY と USD を足し算する」のような単位ミスも型エラーにできます。ランタイムコストはゼロ。型情報はコンパイル後に消えます。newtype の基本newtype は「struct でラップして、別の型として扱う」だけのパターンです。#[derive(Debug, Clone, PartialEq, Eq, Hash)]pub struct UserId(String);#[derive(Debug, Clone, PartialEq, Eq, Hash)]pub struct OrderId(String);impl UserId {    pub fn new(s: impl Into<String>) -> Self {        Self(s.into())    }    pub fn as_str(&self) -> &str {        &self.0    }}impl OrderId {    pub fn new(s: impl Into<String>) -> Self {        Self(s.into())    }}fn lookup_user(id: &UserId) -> String {    format!("looking up {}", id.as_str())}fn main() {    let u = UserId::new("alice");    let o = OrderId::new("o-1");    println!("{}", lookup_user(&u));    // lookup_user(&o); // ← compile error: 期待型は &UserId, 実際は &OrderId    println!("{:?}", o);}UserId の内部表現は String で、OrderId の内部表現も String ですが、型システム上は別物です。lookup_user(&o) を書くと型エラーになります。ergonomics を上げるnewtype はそのままだと使い勝手が悪い (every cast が手書きになる) ので、よく使う変換を From / AsRef / Display で実装します。use std::fmt;#[derive(Debug, Clone, PartialEq, Eq, Hash)]pub struct UserId(String);impl UserId {    pub fn new(s: impl Into<String>) -> Self {        Self(s.into())    }}// `let id: UserId = "alice".into();` で書けるimpl From<&str> for UserId {    fn from(s: &str) -> Self {        Self(s.to_owned())    }}// `id.as_ref()` で `&str` を取れるimpl AsRef<str> for UserId {    fn as_ref(&self) -> &str {        &self.0    }}// 表示は `Debug` と分けて意図的にカスタマイズimpl fmt::Display for UserId {    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {        write!(f, "user:{}", self.0)    }}fn main() {    let id: UserId = "alice".into();    println!("{id}");      // user:alice    println!("{id:?}");    // UserId("alice")}Display と Debug を分けるのがポイント。Debug は開発者向けの構造表示、Display はユーザー向けの整形です。derive_more で boilerplate を削るFrom / AsRef / Display を毎回手書きしたくない場合、derive_more クレートが使えます。[dependencies]derive_more = { version = "2", features = ["as_ref", "display", "from"] }use derive_more::{AsRef, Display, From};#[derive(Debug, Clone, PartialEq, Eq, Hash, AsRef, Display, From)]#[display("user:{_0}")]pub struct UserId(String);これで From<String>, AsRef<String>, Display が自動生成されます。#[display("...")] 属性で書式を指定。手書きより短く済みます。「単位」を newtype で表すnewtype の真価は「数値に単位を付ける」場面で出ます。PhantomData<T> を使って、内部表現は同じ i64 だけど型レベルで通貨を分けるパターン。use std::marker::PhantomData;use std::ops::Add;#[derive(Debug, Clone, Copy)]pub struct Currency<C> {    cents: i64,    _tag: PhantomData<C>,}impl<C> Currency<C> {    pub const fn new(cents: i64) -> Self {        Self { cents, _tag: PhantomData }    }    pub const fn cents(self) -> i64 {        self.cents    }}impl<C> Add for Currency<C> {    type Output = Self;    fn add(self, rhs: Self) -> Self {        Self::new(self.cents + rhs.cents)    }}#[derive(Debug)]pub struct Jpy;#[derive(Debug)]pub struct Usd;fn main() {    let a: Currency<Jpy> = Currency::new(1_000);    let b: Currency<Jpy> = Currency::new(500);    println!("JPY total = {}", (a + b).cents());    let _u: Currency<Usd> = Currency::new(100);    // a + _u; // ← compile error: 通貨が違う}Currency<Jpy> と Currency<Usd> は別の型です。+ は同じ通貨同士でしか定義されていないので、混ぜようとするとコンパイルエラーになります。ランタイムにおける Currency<C> の実体は単なる i64 です (PhantomData<C> のサイズが 0 だから)。型情報はコンパイラだけが知っていて、生成されたバイナリには残りません。安全性を上げてもパフォーマンスは犠牲にならない のが Rust の強みです。いつ newtype を使うか過剰になるケースもあります。判断の目安を表にしておきます。 使う  使わない  ID 系 (UserId, OrderId)  一時的な計算用の数値  単位がある値 (距離, 時間, 通貨)  プレーンな整数の数え上げ  検証済みの文字列 (EmailAddress)  内部の中間表現  公開 API の引数・戻り値  関数内ローカル変数 「同じ String でも、文脈で意味が違う」場面で newtype が効きます。「同じ i64 を足し引きしているだけ」のローカル計算では、newtype を入れると逆に煩雑です。まとめnewtype = pub struct UserId(String); のラッパ取り違えバグを型エラーで止められるFrom / AsRef / Display を実装して ergonomics を上げるderive_more で boilerplate を削れるPhantomData<T> で「単位」を型に乗せられる (通貨, 距離, 時間)ランタイムコストはゼロ公開 API の ID は最初から newtype にしておくと、後で「どこで String を String に渡してミスっているか」を探すデバッグが要らなくなります。関連std::marker::PhantomData: https://doc.rust-lang.org/std/marker/struct.PhantomData.htmlderive_more クレート: https://docs.rs/derive_more/]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[おい、要件を動くものにしろ]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/05/172501</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/05/172501</guid>
            <pubDate>Tue, 05 May 2026 08:25:01 GMT</pubDate>
            <content:encoded><![CDATA[はじめにドキュメントは、書いた瞬間から腐っていく。読まれない。検証されない。更新されない。それでも、要件と呼ばれ続けている。第1部では、要件を言葉にする規律について書きました。引き出し、観察し、ジョブを見極めながら、業務領域の言葉で書く。6つの特性を満たし、検証可能な形にする。揺らぎと固執を組み込んで、自分たちの色を要件に乗せる。そこまでやって、初めて要件文が立ち上がります。ただ、立ち上がっただけでは足りないのだと、最近は思うようになりました。第1部で扱ったのは「書かれずに死ぬ要件」でした。第2部で扱うのは、もう一段先の死、書かれたのに読まれずに死ぬ要件です。気づいたときには、ドキュメントと現場のあいだに、もう橋が架かっていない。syu-m-5151.hatenablog.com要件は、ドキュメントで生きるのではなく、動くものとして生きる必要があるのではないか——というのが、私のいまの現在地です。ドキュメントは補助で、動くものが本体。コード、型、テスト、スキーマ、自動レビュー、CI、PRテンプレート。これらに分散して焼き込まれた要件だけが、毎日読まれ、毎日検証され、毎日少しずつ良くなっていく気がしています。第2部は、言葉になった要件を実装の現場でどう動くものに変えるか、の話です。仕様駆動開発という最近の動きの整理から始めます。AIが書いたコードを信頼できない構造的な理由、要件をシステム全体に分散させて埋め込む方法、そしてレビューの認知負荷をアーキテクチャで減らす設計について、順に書いていきます。このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。仕様駆動開発の「仕様」とは結局何なのか最近の文脈をもう少し丁寧に整理しておきたいです。「仕様駆動開発」という言葉が、急速に広まりすぎて、人によって指すものがズレ始めているからです。最近、AIエージェントにコードを書かせる開発スタイルが急速に広まりました。最初に流行したのは、曖昧な指示から雰囲気でコードを生成させる、いわゆるVibe Codingです。プロンプトを投げる、コードが出る、動かしてみる、気に入らなければ書き直す。私自身、最初の数週間はこれで作業速度が跳ね上がった気がしていました。気がしていただけで、機能を数個足したあたりで全体が崩れ始めた、というのが正直なところです。Vibe Codingは「動くもの」は作れるが、「育つもの」は作れない、というのが業界の総括だったと思います。その反省から、要件を整理しタスクに分解してからAIに渡すアプローチが定着しました。曖昧なまま投げるのをやめ、上流で意図を固めてから下流に渡す。これが現在の主流になっています。仕様駆動開発（Spec-driven Development）は、その代表的な形です。ただ、用語が広まる速度に対して、用語の中身の整理が追いついていません。「仕様駆動」という言葉が指す範囲は広く、人によって思い描いている前提が違う。仕様を実装のインプットとして使うだけなのか、それとも仕様そのものを本質として扱うのか。仕様書は実装後に捨てるのか、永続化するのか、あるいは仕様こそが正でコードは派生物なのか。立場によって、議論の前提が大きく変わります。そして、仕様駆動開発と並んで議論されるのがPlanモードです。Vibe Codingを乗り越えるための仕組みという意味では同じですが、性格が違うものだと考えています。Planモードは、実装したがるAIエージェントへ「まず考えさせる」ためのブレーキです。実装に入る前、必要な情報を収集させ、計画を立てさせ、ユーザーに見せる。承認を得てから実装に進む。多くのCoding Agentに搭載されている、いわば承認ゲートの仕組みです。仕様駆動開発もブレーキ機能を持っています。実装の前に仕様を書かせ、レビューさせる。Vibe Codingの欠点を補うという意味では、Planモードと同じ方向を向いている。だから両者は混同されやすい。ただ、仕様駆動開発のほうは論点が多い。私が現場で議論するとき、繰り返し出てくる疑問が3つあります。疑問① 「仕様(Spec)」とは何なのか最初の疑問はシンプルだが、意外と答えが揃いません。「仕様」という言葉に、人それぞれ違う絵が浮かんでいます。最近の仕様駆動開発の文脈では、仕様はバージョン管理可能で、人間が読める、一種のスーパープロンプトとして定義されます。あるツールは仕様を「実装前にCoding Agentが必要とする入力情報」と位置づけ、別のツールは「静的な文書ではなく、プロジェクトと共に進化する生きた実行可能な成果物」と位置づける。表現は違うが、共通しているのは、従来の仕様書とは別物だ、ということです。従来の仕様書は、人間のために書かれていました。設計者が書き、エンジニアが読み、テスターが照合する。書いた瞬間から劣化が始まり、ほどなく無効になる。これが普通の仕様書の運命でした。新しい意味での仕様書は、AIエージェントのための入力です。書く目的が変わった。読み手が機械になった瞬間、書かれ方も変わる必要が出てきます。曖昧な表現は機械にとって毒です。一方で、人間にしか判断できない文脈は、機械が混乱しないよう構造化して書く必要がある。私はこの違いを、自分の中で「仕様の宛先が変わった」と書いてきました。宛先が人間だった頃は、行間で伝えられた。宛先が機械になった瞬間、行間は読まれない。書かれていることが全てになる。第1部で出した「在庫がある」の2文字を書き忘れる構造は、まさに宛先が変わったことに気づいていないときの事故パターンです。ここで、自分の中で長く曖昧だった用語を整理しておきます。「要件」と「仕様」と「契約」は、何が違うのか。要件は、業務として何を達成したいかを書いたものです。「在庫がある商品だけを検索できる」。問題領域の言葉で書く。誰がなぜ何を求めているか、を表現します。仕様は、要件を実現するためにシステムが何をするかを書いたものです。「検索APIは、stock_count > 0 のフィルタを適用し、関連度順に最大20件を返す」。要件と実装の中間にある翻訳層です。要件の言葉を、エンジニアが実装に変換できる粒度まで落としたもの。契約は、仕様を呼び出す側と呼び出される側で守るべき約束として書いたものです。「このAPIは200を返すならbody.itemsは存在し、各itemはid/name/priceを持つ。500を返すならbody.errorに理由が含まれる」。これは型シグネチャ、APIスキーマ、関数の事前/事後条件として書ける。3つは別物です。仕様駆動開発を語る人の多くは、この3つを混ぜて使っています。混ぜると議論が壊れる。「仕様を書けばAIが実装する」と言うとき、その「仕様」が要件・仕様・契約のどれを指すかで、必要な精度や書き手が全く違うからです。疑問② 作成された仕様書をどう扱うか2つ目の疑問は、仕様書の寿命の話です。書いた仕様書は、実装後にどう扱われるのか。これも、立場で大きく分かれます。私の中では、仕様書は3種類に分かれます。実装に入る前に捨てる紙、実装と並走して更新する地図、実装そのものを生成する金型。業界の語彙では Spec-first / Spec-anchored / Spec-as-source と呼ばれていますが、議論を始める前に、この3つを別物として扱うかどうかが土台になります。踏み台としての仕様は、書いた瞬間に役目を終える紙です。Coding Agentに前提を渡すための事前準備で、マージと同時に消えても困らない。実装後に読まれない仕様文書が残っているのは、たいていこの踏み台が捨てられずに置きっぱなしになっているだけです。地図としての仕様は、実装と並走して書き直されます。地図と現地が食い違ったら、両方が間違っていると疑う。仕様と実装のどちらかを正にするのではなく、互いに照らし合いながら更新する。多くの現場で「仕様駆動開発」と呼ばれているのは、この運用に近いと思っています。金型としての仕様は、コードがそれに従属します。仕様を直すとコードが直る、という前提で動かす。型・テスト・スキーマで書ける部分はこちらに寄せられる。一方で、自然言語の仕様をそのまま金型として使うのは、現状のAIの精度ではまだ無理がある、と私は感じています。ここに大きな分岐があります。同じ「仕様駆動開発」と言っても、踏み台と金型では運用負荷も得られる効用も、まったく違う。SNSで仕様駆動の論争が噛み合わないのは、議論の土台がここで分かれているからだと思います。私自身は、金型に片足を突っ込みつつ、現実は地図として運用する、というハイブリッドに落ち着いています。型・テスト・スキーマとして書ける部分は金型に寄せ、業務の文脈やトレードオフは地図として運用する。3つを排他的な選択肢ではなく、要件の性質ごとに重ねるレイヤーとして扱う、というのが私の現在地です。疑問③ ウォーターフォールへの先祖返りなのか3つ目の疑問は、もう少し感情的な反発が混じったものです。「仕様駆動開発は、結局ウォーターフォールの復権ではないか」。仕様書を信頼できる唯一の情報源（Single Source Of Truth）として扱う流派の話を聞くと、確かに、開発の中心がソースコードからドキュメントへ戻っているように感じます。アジャイルが「動くソフトウェアこそが価値」と言ってきた歴史を踏まえると、仕様書中心の世界観には違和感がある。私はこの違和感を、半分は正当で、半分は誤解だと思っています。正当な部分は、仕様書を聖典化することの危険です。仕様が更新されない、仕様の変更が重い儀式になる、仕様がチーム内の権力構造を反映する。これらが揃うと、ウォーターフォールの悪い側面が再現されます。仕様駆動を導入したつもりが、実態は計画駆動の硬直したプロセスになっている、というのは十分にあり得る失敗です。誤解の部分は、仕様駆動の仕様は、ウォーターフォールの仕様書とは性格が違うということです。ウォーターフォールの仕様書は、フェーズの成果物でした。書いて、レビューして、承認されたら次の工程に進む。書き直しは別工程の手戻りで、コストが高い。仕様駆動の仕様は、AIエージェントを動かすための制御手段です。実装と並走し、実装と一緒に書き直される。生きたドキュメントとしてAIエージェントへ最新の仕様を渡せることが、柔軟性を支えています。書き直しは手戻りでなく、通常運転の一部です。各フェーズの厳格な順序性、前工程への戻りの難しさ、これはウォーターフォール特有の特性であって、仕様駆動が必然的に持つものではない。両者を混同すると、議論が空回りします。新しいPlanモードへの合流ここまで読んで、「仕様駆動開発という手段を取らずとも、Planモードで同じことができているのでは」と感じる人もいると思います。これは正しい直感です。Planモードはもともと「AIエージェントの暴走を防ぐ」ためのブレーキでした。最近のPlanモードは、仕様ファイルの作成やタスクリストへの分解までやるようになっています。Spec-firstのワークフローを取り込んで進化した結果、仕様駆動開発との境界が曖昧になっているのが現状です。新しいPlanモードと仕様駆動開発の違いは、ほぼ一点に絞られます。仕様書を実装後にどう扱うか、つまり前述のSpec-first / Spec-anchored / Spec-as-sourceのどれを取るか、です。Planモードは、基本的にSpec-firstとして振る舞う。Coding Agentが計画を立て、実装し、計画ファイルは実装と共に消える。それで困らないなら、Planモードで十分だと思います。これから新しく仕様駆動開発を採用するなら、Spec-firstでは差別化できません。Spec-anchoredかSpec-as-sourceの方向に踏み込まない限り、Planモードと変わらない。仕様駆動の真価は、仕様書の永続化と回転にある、というのが私の見立てです。仕様はあればあるほど良いのかもう1つ、現場でよく聞かれる疑問があります。「仕様を詳細に書けば書くほど、AIの成果物の品質は上がるのか」。直感では「上がる」と答えたくなります。指示が詳細なら、ブレが減るはずだ、と。実際にはそう単純ではないと考えています。AIエージェントに渡すトークン数が膨大になると、性能が劣化すると言われています。Context Rot（コンテキストの腐敗）と呼ばれる現象で、長すぎるコンテキストは精度を下げる。さらにLost in the Middleという現象もあって、先頭や末尾に比べ、中間に配置された情報の認識精度が大幅に下がる。プロンプトが過密になると、AIは本来重要な情報を見逃します。仕様を詳細に書こうとして、何千行もの仕様書を1つのコンテキストに詰め込むと、AIはその中で迷子になる。詳細さが裏目に出るパターンです。ここで効くのが、繰り返しになりますが、決定論的なゲートとの組み合わせです。仕様の文章で全てを書き尽くそうとせず、テスト・lint・型・CIパイプラインなどの自動化された検証に任せる部分を増やす。仕様書には、人間にしか判断できない部分、AIに伝える必要がある業務文脈、を厚く書く。残りは決定論で守らせる。満たすべき要件をプロパティベーステストとして書くのも有効です。「どんな入力でも、検索結果には在庫切れ商品が含まれない」をテストとして書けば、仕様書に長文で書くより厳密で、AIが書いたコードを自動で弾けます。仕様の量と質は、トレードオフです。書きすぎると埋もれる。書かなさすぎると穴が開く。ちょうど良い情報量を、コンテキスト全体で設計する。これがコンテキストエンジニアリングの本質だと思っています。仕様駆動はどこへ向かうのか私の考えをまとめます。「仕様駆動開発」という用語は、まだ明確に定義されていません。採用する粒度やスタイルを、チームで認識を合わせる必要があります。Spec-firstのスタイルはPlanモードに合流しつつあり、近いうちにCoding Agentの標準機能の一部になるはずです。新たに仕様駆動を採用するなら、Spec-anchoredかSpec-as-sourceへ踏み込むことになる。ただ、完全な仕様を作り上げ、維持し続けることは想像以上に難しい。仕様の保守が新しい技術的負債になる可能性もある。もう1つの方向は、仕様を厚くするのではなく、検証を厚くするという考え方です。仕様は最低限に保ち、テスト・型・契約・自動レビューでガードする。AIが書いたものを仕様に照合するのではなく、AIが書いたものを検証ハーネスにかける。私はこちらに賭けています。賭けている、と書いた以上、何が観測できたら賭けを降りるかも書いておきます。検証を厚くした結果、PRの差し戻し率や本番障害率が、仕様を厚くしたチームと有意に変わらなかったら、私はこの主張を撤回します。あるいは、ハーネスの維持コストが書く時間を上回り、複利資産でなく減価償却資産になってしまうなら、戦略の組み替えが必要です。いまのところ、自分のプロジェクトと観測している現場の範囲で、ハーネス側へ倒した方が事故は少ない。だからこちら側へ賭けている、というだけの話です。そして、私にとって仕様駆動開発は、要件をシステム全体に焼き込む話の一形態に整理されます。仕様という言葉が独立した運動を作っているのではなく、要件をシステムの中でどう表現するかという問題に還元される。仕様という箱を作るのではなく、型・テスト・契約・スキーマ・状態機械という具体的な道具で、要件を実装に翻訳する。それが私の現在地です。ここで誤解されないように書いておきたいことがあります。「動くものに散らす」というのは、新しいフレームワークを表層で導入する話ではない、ということです。CIを足し、lintを足し、PRテンプレを揃える。これらは儀式として整えれば整うほど、芯から遠ざかることがあります。芯から変わるとは、コードベース・チームの判断・組織のレビュー文化が、要件を中心に再配置されることです。儀式の追加でなく、判断の置き場所が動く。それが起きていないなら、ハーネスは形だけになって、複利資産にならず減価償却資産に逆戻りします。なぜAIが書いたコードは信じられないのかAIが書いたコードを、レビューに普段の3倍の時間がかかった、という話を最近よく聞きます。私自身も同じ経験をしました。自分の手元で測ったことがある。人間のPRなら15分で終わるレビューが、AIのPRだと45分かかった。差は3倍。でも内訳を見ると、コードを読む時間は変わっていません。膨らんでいるのは「これは本当にこの書き方でいいのか」を毎行ごとに自分に問い直す時間です。書く時間は確かに短くなった。だがレビューに時間が溶ける。AIが速くなったというより、読む側の自分が遅くなった、という言い方のほうが正直に近い気がしています。差し引きで本当に速くなったのかは、まだ分かりません。なぜか。私はこれを長く考えてきました。今のところの答えは、信頼の前提が足りていないから、です。人間が書いたコードをレビューするとき、私たちは多くの前提を共有しています。書き手の癖、過去の議論、チームのコーディングスタイル、ドメインの常識。これは明示的にはどこにも書かれていないが、レビュー時には全員の頭の中にある。だから「これは違うな」と直感で判断できる。直感の根拠は、言語化されていない共有知です。AIが書いたコードには、この共有知がありません。書き手は誰でもなく、あるいは全員だ。書き手の癖は、世界の平均値です。あなたのチームのスタイルではない。だから1行ずつ、明示的に「これは合っているか」を判定する必要が出てくる。直感が効かないのです。直感が効かないコードのレビューは、コードを書くより重い作業です。書くときは自分の知識の流れに沿って書ける。レビューは他人の流れを再構築しながら検証する。AIがその「他人」になったとき、再構築の難度は跳ね上がります。ここで多くの人がやってしまうのは、プロンプトの精度を上げるという方向に走ることです。延々と続くプロンプトを書く。「あなたは熟練のRustエンジニアで、このプロジェクトのコーディング規約に従い、エラーハンドリングは…」と続く。これは効くこともあるが、本質ではないと考えています。本質は、AIに渡せる前提の量を増やすことです。プロンプトの中に書くのではなく、プロジェクトの構造・コードの形・テストの存在として、AIが参照できる形で前提を置いておく。私が一番効くと感じているのは、ドメイン知識の構造化です。プロジェクトのミッション、ビジネスの境界、登場するエンティティ、それらの関係。これらを口頭の文化に置いたままにせず、プロジェクト全体ドキュメントなり設計ドキュメントなりに、明示的に書き出しておく。書き出す行為そのものが、要件の言語化です。書いた瞬間に「あれ、これってどうだったっけ」が露出する。それは要件の穴の発見であって、隠してはいけない情報だ。次に効くのが、責務の分離です。コードベースが1つの巨大な塊になっていると、AIはどこを変更すれば良いか判断できません。境界が引かれていれば、「このBounded Contextの中で完結する変更」と指示できる。境界はAIへの命令の解像度を決めます。境界が無いと、命令も曖昧にしかならない。その手前にあるのが、プロセスの再設計です。レビュー段階で要件を発見するのではなく、上流で合意を取る。AIへ依頼する前、要件の合意ができているか自問する。合意できていないなら、依頼はまだしてはいけない。「とりあえず作らせて、見ながら考える」は、試作で捨てる前提なら有効です。本実装に合意のないまま渡すと、コストが大きく膨らむ。実装と差し戻しを何度も繰り返すと、その時間は積み重なって膨らみます。最初に合意を取っておけば、ずっと短く済んだはずです。試作と本実装の境界を、自分の中ではっきりさせておく必要があります。そしてもう一段、見落としやすいのが、チーム展開です。1人がAIをうまく使えても、チームで効果を出すには、共通の使い方が必要になります。ペアでAIを使う。モブでAIを使う。AIへの指示の仕方を、暗黙知から明示知に変えていく。これは個人の生産性ではなく、組織の生産性の話だ。チームのプロンプトを「個人芸」から「共有資産」へ移すAIへの指示の仕方を、暗黙知から明示知に変えていく、と書きましたが、これだけでは抽象的すぎます。具体的に何をどこに置くか、を書きます。私のチームで最初に起きたのは、「同じ機能を、人によって全然違う指示で書かせている」という事態でした。Aさんは英語の長文プロンプトで書かせる。Bさんは「いい感じに書いて」で済ませる。Cさんは雛形を使い回す。出てくるコードは、当然ばらつきます。レビューの粒度もばらつく。3人が同じドメインを触っているのに、3つの方言が並走する状態でした。ばらつきの原因は、能力差ではない。指示が個人のローカルストレージにしかないことです。誰かのチャット履歴、誰かのCursorルール、誰かの頭の中。リポジトリには無い。だから共有も改善もされません。私たちが切り替えたのは、こうです。プロンプトはコードと同じ場所で、同じレビュープロセスで管理する。.claude/skills/、.cursor/rules/、AGENTS.md、PRテンプレートに書かれたチェックリスト。ここに置かれたものだけが、チームのAI指示として認められる。個人のチャット欄に書かれた指示は、その人が辞めたら消える。リポジトリに書かれた指示は、次の世代に残る。「個人のチャット履歴に書いた指示は、その人が辞めた瞬間に消える」と書いて、自分でも少し怯みました。私自身、いまだに思いつきの指示はチャット欄に直接書いています。1日に何度も。それを全部リポジトリに上げる気力はありません。だから、「3回繰り返したらリポジトリに引っ越す」を個人的なルールにしています。3回未満は個人芸で構わない。3回を超えたら、それはチームの慣習になりかけている。慣習はリポジトリに置かないと共有資産にならない。もう1つ、ペアやモブでAIを使うときに気づいたことがあります。他人がAIに出す指示を見ると、自分の指示の癖が露出する。「そんな前提を渡さずに頼んでたのか」「その聞き方だと、こういう事故が起きるな」が、画面共有の30分で何度も起きます。これはコードレビューとは別種の学びで、指示そのものが新しいレビュー対象として浮上してきた、という感覚があります。ただし、ここにも撤回条件を書いておきます。3人未満のチーム、あるいは触るドメインがほぼ1つに収まるチームでは、プロンプトの共有資産化は過剰投資になりやすい。個人芸のままで回ります。私が話しているのは、5人以上で複数ドメインを触っているチームの話です。それ未満の規模では、まずドメイン構造化に投資したほうが、たぶん効きます。ただ、これは私の現場での感覚で、撤回する条件を書いておきます。ドメインの境界が浅く、登場するエンティティが10個未満で済むような小規模プロダクトでは、構造化のコストがリターンを上回ります。また、すでに巨大モノリスになっているコードベースでは、責務分離のほうが先に効くと思っています。「うちの規模・段階でドメイン構造化が一番効くか」は、各チームで観察してから判断してください。プロンプトに気合を入れるより、ドメインの境界を一枚の図にするほうが、はるかに効きます。エンティティ・関係・操作・状態遷移を、AIが読める形で置いておく。AIはそこを参照して、コンテキストに沿った実装を返してくる。プロンプトで指示するより、コンテキストで縛るほうが安定します。これは結局、要件の話に戻ってきます。AIが信じられないのは、AIに渡している要件が信じられないからです。曖昧な要件、矛盾した要件、暗黙の前提を含んだ要件。これらを渡せば、AIは曖昧で矛盾した、暗黙の前提を勝手に補完したコードを返してくる。信頼ギャップは、AIの能力ギャップではない。要件のギャップです。AIへ仕事を投げると、代替案もメリットとデメリットも丁寧に並べてきます。並ぶのですが、並んだもののうちどれを採るべきかの軸を、AIは自分で選びません。いまの場面で何を最も重んじるか、その判断の中心に据えるべき軸は、空白のまま手元に返ってきます。空白のまま受け取った AI は、その場面の文脈に合った軸ではなく、平均的にもっともらしい軸——「読みやすさ」「一般的な拡張性」「世間でよく見るベストプラクティス」——を勝手に中心に据えてきます。出てきたコードは見栄えがよく、論理も整っていて、それでいて場面の主軸とずれている。レビューが3倍に伸びるのは、コードを読む時間ではなく、選ばれた軸がこの場面と合っているかを毎行ごとに照合し直す時間です。これは、3倍に伸びた45分の内訳を一度自分で記録した結果として、そう書いています。そして、現場で繰り返し観測される設計の事故——標準解があるのに独自実装に逸れる、変化点が観測されてもいない段階で抽象化を入れる、運用組織のスキルを見ずに技術的に望ましい構成を採る、責務本体に書くべきものを近場の便利な場所へ書く——の多くは、AI 以前から存在していました。違うのは件数と頻度です。たぶん桁が1つ違う。人間がこの場面の主軸を意識化していないと、エージェントは黙ってその空白を平均で埋め、平均で埋まったコードが大量に流れ込みます。AIの間違いは新種ではなく、人間が支えていた主軸の選び方を継ぐ仕組みが先に崩れたことの帰結だ。半年ほど現場の事故報告を眺めて、そう書き直しました。賭けている、と書いた以上、降りる条件も書いておきます。AI コードに固有で AI 以前には観測されなかった種類の事故を、新規パターンとして列挙できるようになったら、私はこの見立てを撤回します。今のところ、手元のメモにそういう新規パターンの記録はありません。そう理解してから、AIへの不満が減りました。AIに対する期待値が、自分のドキュメントの精度を超えなくなったからです。AIは私のドキュメントの鏡だ。鏡に映る姿が気に食わないなら、文句を言う先は鏡ではなく自分だ。そして、レビューの話には経済的な側面もあります。欠陥は、見つかる段階が後ろに行くほど、修正コストが桁で跳ね上がる。要件で見つけたものは要件文を直すだけ。実装で見つけたものはコードと設計と要件を直す。運用で見つけたものはそれに加えて顧客対応と再発防止策が乗る。AIで実装が速くなったぶん、要件レビューの相対的な投資対効果はさらに上がっていると感じます。レビューをドキュメントに対してではなく、要件・テスト・型・契約の組み合わせに対してかける、というのが、いま私が現場で守っているラインです。要件を動くものに散らすここからが、第2部で一番伝えたい話です。要件をドキュメントだけで保つことを、私はやめました。ドキュメントは補助に格下げ。要件の本体は、ドキュメントではなく動くものに置く、というのが現在の方針です。動くものとは何か。エージェントが起動するたびに読まれるCLAUDE.md。毎回参照されるskill。CIで毎回実行されるテスト。コンパイルのたびにチェックされる型。PRごとに走るlint。レビュー時にAIが参照するチェックリスト。これらに分散して焼き込まれた要件です。ドキュメントで書いた要件は、誰も読みません。読まれない要件は、要件ではない。一方、エージェントが毎起動で読むファイルに書かれた要件は、毎回読まれます。テストとして書かれた要件は、CIで毎回検証されます。毎回読まれる、毎回検証される。これが、要件が生き続ける条件です。私のブログのリポジトリを例に取ります。このリポジトリには .claude/rules/voice.md というファイルがあって、文体ルールが書かれています。「です・ます調で統一する」「『非常に〜』を多用しない」「『おい』シリーズの構成パターンはこうだ」。これは要件文書です。そう呼ばないだけで、内容は要件です。このファイルは、Wordに置いていたら今頃腐っていたと思います。誰も読み返さない。書いた本人すら忘れる。それがドキュメントの要件文書の運命です。このファイルが生きている理由は、CLAUDE.mdからリンクされていて、毎回のセッションで読み込まれるからです。AIエージェントが記事を書くとき、レビューするとき、毎回参照される。そして実際に違反があれば指摘される。実行可能な要件になっている。これと同じことを、プロダクト開発でやればいい、というのが私の考えです。どこに散らすか動くものに散らす、と言うとき、要件は種類によって違う場所に置かれます。私の手元では、おおよそ次のような置き場の使い分けに落ち着いています。プロジェクト全体に関わる要件——ミッション、対象ユーザー、ビジネス上の制約、絶対に避けるべきこと——は、CLAUDE.md に書きます。これは頻繁に変わらない、静的な要件です。ドメインごとの要件は、領域別のドキュメントと skill に書きます。「在庫管理ドメインでは、商品は在庫数とは別にステータスを持つ」「在庫切れ商品は検索結果から除外される」「ステータス変更は監査ログに残す」。設計の文脈で参照される層です。機能ごとの要件は、コミットメッセージと PR のテンプレート、テストファイルの先頭コメントに置きます。「この機能は何を達成するか」「合格基準は何か」を、その機能のコードと同じ場所に置いておく。コードと近いから、コードと一緒にメンテナンスされる。「コードは fmt を通す」「unwrap は使わない」「テストカバレッジ 80% 以上」のような手続き的な要件は、CI のワークフローと lint のルールに書きます。これは書いた瞬間に強制されるので、型と同類です。最後に、振る舞いそのものの要件は、テストに書く。プロパティテスト、結合テスト、E2E テスト。「在庫切れ商品は検索結果に含まれない」というテストは、ドキュメントの要件文よりも厳密で、実行可能で、リファクタリングしても残ります。並べると整然として見えますが、現場で難しいのは「どの種類の要件がどの層に属するか」の判断のほうです。私はここで何度かミスをしました。たとえば「在庫切れ商品は検索結果から除外する」を、最初はskillとPRテンプレートの両方に書いていた時期があります。書く場所が2つあるから、片方だけ更新されて、もう片方が古いまま残る。ドキュメントの腐敗を、散らした先で再生産していたわけです。逆に、「unwrapを使わない」のような手続き的な要件をskillにだけ書いて、CIに落とさなかった時期もあります。書いてあるのに守られない。読まれるが、検証されない。散らす場所を増やすほど、要件が「重複して腐る」リスクと「書いたのに効かない」リスクが両方増える。だから私のいまの規律は、1つの要件は1つの場所に置く、置く先は実行のどの瞬間に検証されるかで決める、です。散らすこと自体が目的ではありません。もう1つの規律として、自動生成できるものは人間が書かないを徹底しています。型定義から生成できるAPIドキュメント、ソースコードから生成できる依存関係図、テスト結果から生成できるカバレッジレポート、CLAUDE.mdから生成できるskillの索引。これらを人間が手書きすると、書いた瞬間がピークで、あとはコードと乖離していくだけです。生成元はコードや型なので、それ自体が動くもので、変更すれば自動的に追従する。手書きの要件は陳腐化のリスクを背負い、自動生成の要件は背負わない。書く場所を選ぶ前に、書かずに済む場所はないか、と自問するようにしています。これら全てを合わせて、動くものに散らされた要件と呼んでいます。1つの場所にあるのではなく、コードベース全体に散らばっている。散らばっているが、それぞれが実行されるときに参照される。ドキュメントのように埃をかぶることがない。受け入れ基準を「実行可能な形」に翻訳する散らす場所を決めたら、次にやるのは、受け入れ基準を実行可能な形で書くことです。これが、要件を動くものに移すときの中心の作業になります。要件を書いた後、「満たしたかどうかをどう判定するか」を必ず書く。判定方法をテストとして書ければ、要件はテストとして実装されたと同じです。判定方法を型として書ければ、要件は型として実装されたと同じ。判定方法こそが要件の本体だ、という見方です。例えば、Given-When-Then形式で受け入れ基準を書く。「在庫が0の商品が存在する状態で、その商品名で検索すると、結果に含まれない」。これは自然言語で書いた1行ですが、ほぼそのままテストに翻訳できます。書いた瞬間に実装と一対一の関係になる。判定可能な受け入れ基準を持たない要件は、まだ要件になっていない願望です。実装は受け入れ基準にしか反応しない。願望のまま実装に渡すと、AIは想像で判定基準を作り、そこに合わせます。私たちのチームが望んでいた基準とは違う何かが、判定基準として採用される。受け入れ基準には、満たすべき条件だけでなく、満たしてはいけない条件も書きます。「在庫切れ商品は結果に含まれない」は満たしてはいけない条件の宣言です。これを書かないと、AIは「含めても満たせるかも」という余地を残します。含めない、と書くことで、初めて要件として閉じます。そしてもう一段、私が最近受け入れ基準に書き足すようにしているのは、意図してやっていないことの宣言です。「論理削除は今回のスコープ外」「多言語対応は対象外」「キャッシュは現バージョンでは入れない」。書かないと、AIは気を利かせて未来の拡張を実装に紛れ込ませます。良かれと思って入れた論理削除フラグが、要件にない複雑さを実装に持ち込んで、レビューで剥がす作業が発生する。やらないことを書かないと、やられる。受け入れ基準は、満たす条件と満たさない条件と意図しない条件、3つで初めて閉じる、というのが今の私の運用です。決定論と確率論の分業要件を動くものへ散らすとき、もう1つ重要な性質があります。決定論と確率論の分業です。AIで開発するときに難しいのは、AIの確率的な振る舞いと、システムの決定論的な要請をどう繋ぐかです。AIに自由にコードを書かせるとバグが出る。完全に縛ると価値が出ない。この間でバランスを取る必要がある。要件を散らす場所は、このバランスを設計する場所です。決定論的に守らせたいことは、lint・型・テストで縛る。AIはこれらを破れない。確率論で柔軟に扱いたいことは、プロンプト・skill・ガイドラインで導く。AIはこれらをヒントに自分で判断する。要件のうち、決定論で表現できるものは、決定論側に焼き込みます。「在庫数は0以上の整数である」は型で書ける。「APIのレスポンスは200・400・500のいずれかを返す」は型で書ける。これらをAIに守らせる必要はない。型が守る。決定論で表現できない要件、たとえば「エラーメッセージはユーザーフレンドリーな日本語で書く」「コミットメッセージはConventional Commits形式で書く」は、確率論側に置きます。プロンプトで指示する。skillで例示する。AIが大体守る。完全には守らないこともあるが、レビューで補正する。このハイブリッドが効くのは、人間の認知資源を温存できるからです。決定論で守られている部分は、レビューする必要がない。型エラーが出るならコードはコンパイルされていない。テストが通っているなら、テストされている要件は満たされている。レビュアーは確率論側、つまり人間にしか判定できない部分だけを見ればいい。私の手元では、これを「人間のレビュー領域を最小化する設計」と書きました。AIで開発が速くなったぶん、人間がボトルネックになる。人間のボトルネックを減らすには、人間が判定すべき事柄を減らすしかない。それを減らす道具が、決定論的な検証の仕組みです。複数の表現で同じ要件を書く同じ要件を、わざと複数の表現で書くことがあります。一見冗長ですが、これをやると要件の品質が底上げされる、というのが私の手応えです。例えば、ある検索機能の要件を書くなら、次のようになります。自然言語で「在庫切れ商品は検索結果に含まれない」と書くユースケースとして「ユーザーが商品を検索する → 在庫切れ商品を除外して結果を返す」と書く型として fn search(query: SearchQuery) -> Vec<InStockProduct> と書くテストとして「在庫0の商品が結果に含まれないことをアサートする」と書く4つの表現は、同じ要件を別の角度から記述しています。1つでも矛盾していれば、要件の理解にズレがあったということです。書いている最中に矛盾を見つけ、ステークホルダーへ戻って確認する。これが要件の妥当性確認の実体です。複数の表現を併走させると、それぞれの限界が補い合います。自然言語は文脈を伝えられるが、曖昧。型は厳密だが、業務の文脈を伝えにくい。テストは実行可能だが、網羅性に頼ると膨大になる。これらを組み合わせることで、1つの表現では届かない網羅性に到達できる。これは見積もりの分野で言われる「複数の手法で見積もりを出して、収束しているかを見る」という発想と地続きです。1つの手法に頼ると盲点が出る。複数の独立した手法で同じ対象を測ると、収束しているなら信頼できる、ばらついているなら何かを見落としている、と判断できる。要件も同じで、複数の表現で同じ対象を書いて、収束を確認するのが、品質を底上げする技法です。検証は階層で組む、ハーネスは複利で効く要件をテストや型へ焼き込む話を続けます。1つ書き加えておきたいのが、検証は1層でなく多層で組むという考え方です。検証を1つの層で済ませようとすると、必ず漏れが出ます。テストだけで守ろうとすると、テストにない経路の振る舞いが抜ける。lintだけで守ろうとすると、振る舞いの欠陥が止められない。型だけで守ろうとすると、動的に決まる業務ルールが拾えない。1層では破れる。何枚か張って初めて、漏れが減る、というのが現場で繰り返し気づかされたことです。私が組むようになった検証の階層は、3つの瞬間に対応します。コードがどの境界を越える瞬間に、何を守るか、で分ける。1つ目は、書いている瞬間の関所。エディタの保存、ファイル単位の書き換えが終わった直後。フォーマッタ、即時のlinter、構文チェック、軽い型チェック。書き手のリズムを止めない範囲で、間違っていれば即座に直る。検証というより、ほぼ自動修正です。速さが命で、軽くて何度でも回せる、ということがこの層の存在価値。エージェントが書いた瞬間にも同じく走らせます。2つ目は、共有する瞬間の関所。コミット、プッシュ、マージ、レビュー依頼。コードが個人の手元から、チームに渡る境界です。ここでは、全lint、深い静的解析、ユニットテスト、結合テスト、E2Eテスト、契約テスト、セキュリティスキャン、までを通します。秒で済むものから数十分かかるものまで含む、幅のある層です。「マージできた」という事実が、ここを通過した証拠になる。重い検証は、共有の瞬間にだけ集中する、と決めることで、書く瞬間は軽く保てます。3つ目は、完了を宣言する瞬間の関所。AIエージェントが「タスクを終えた」と宣言する直前、または人間が「これで出します」と言う直前。テストが本当に緑か、lintが本当に通っているか、要件で書いた合格基準を本当に満たしているかを、機械的にもう一度確認する。これは私が比較的最近、運用するようになった層です。エージェントは「やりました」と言うが、実は失敗している、というパターンが現場で繰り返し起きます。自己申告を信じない仕組みが、最後の関所になる。エージェント時代に固有の、新しい層です。各層は、守る対象が違います。1つ目は「形式」を守る。2つ目は「振る舞いと統合」を守る。3つ目は「完了の真実性」を守る。何を守りたいかで層を選び、層ごとに違うツールを置く。全部の層を最初から完璧に組む必要はありません。痛い思いをした順に層を増やしていく、というのが現実的な作り方です。そして、層を増やすときの作法もはっきりしています。人手のチェックリストに頼らない。チェックリストは便利ですが、人間が確認する以上、忙しい週には飛ばされます。同じ確認を機械が回せる形に書き直す。lintルール、CIスクリプト、PRブロッカー。機械に渡せる確認は、機械に渡してから人手を考えるというのが、層を足すときの判断順です。検証の層は、一度に組み上げるものではない。痛みのたびに1枚ずつ増えていく。3日連続で同じ事故が起きたら、その形のチェックを足す。半年経って起きなくなっていたら、その層は組織の習慣として定着したと見ていい。仕組みは意志ではなく、繰り返された動作の堆積として残ります。意志に頼って守る規律は、忘れた瞬間に消える。動作として残った規律だけが、次の世代にも伝わる気がしています。この多層化が効く理由は、投資のコストが複利で積み上がるからです。1つlintルールを追加すれば、以降すべてのエージェントセッションでそのミスが防がれる。1つテストを追加すれば、以降すべてのセッションでその回帰が検出される。書いた瞬間ではなく、書いた後の運用で価値が複利で効いてくる。一度書いた検証が、以後ずっと働き続けます。そして、この多層検証は非機能要件の自動測定にも使えます。「循環依存を作らない」「ある層から別の層を直接呼ばない」「テストカバレッジが一定以上」「主要画面の応答時間が一定以下」のような構造的な特性は、CIで実行可能なチェックへ変換できます。これは、要件の中でも特に書いた瞬間に陳腐化しやすい部類のものを、実行時に毎回確かめる仕組みへ置き換える発想です。通常のテストが「振る舞いが正しいか」を確かめるのに対し、こちらは「設計上の特性が崩れていないか」を確かめる。重要だが緊急ではない、モジュール性や保守性のような特性を、緊急の圧力から守る装置として機能します。要件文に「保守可能であること」と書くだけでは何も守られませんが、「特定のモジュールから別のモジュールへの直接依存が増えていないこと」を毎日CIで測れば、保守性は実体として守られます。非機能要件を、測定可能な実行可能な仕掛けに翻訳する。これも、要件を動くものに散らす作業の一部です。これはドキュメントへの投資との非対称な関係です。ドキュメントは書いた瞬間がピークで、あとは陳腐化していく。検証ハーネスは書いた瞬間が始まりで、運用するほど価値が積み上がっていく。ドキュメントは減価償却資産、ハーネスは複利資産。要件を動くものに散らす最大の理由は、ここにあります。もう1つ、検証ハーネスの重要な性質があります。エージェントがコンテキストとしてアクセスできないものは、エージェントにとって存在しないということ。プロンプトに書いていないルール。CLAUDE.mdに書かれていない制約。口頭で伝えただけの慣習。これは、AIにとって存在しないのと同じです。逆に言えば、コンテキストに書かれているもの、CIで実行されるもの、型として強制されるものだけが、AIに伝わります。だから、要件は届くべき場所に置かれている必要がある。要件を書いた、と満足するのではなく、「これは本当にエージェントに届くか」「実行のどの瞬間に検証されるか」を確認する規律が、ハーネス運用の中心にあります。アーキテクチャで主観レビューを減らす要件を動くものへ散らした後に、何が手元に残るか。私の現場では、人間が立ち止まって判断する場所だけが残りました。これをどう削るか、が次の話です。AIで開発するときの検査を、私は4象限で見ています。縦軸に「客観／主観」、横軸に「予防／回復」を取る。  予防  回復  客観  自動化・スケール化  監視・障害対応  主観  認知負荷削減（アーキテクチャの仕事）  リスク受容判断 客観×予防は機械的に判定できて事前に止められるもの（lint・型・テスト・CIゲート）。客観×回復は機械的に判定できて事後に対処するもの（監視・SLO・SRE）。主観×回復は人間が見て初めて分かる問題に、起きてから対応する領域（ポストモーテム・インシデントレビュー）。いずれも仕組みが整っている。問題は主観×予防です。人間が見ないと分からない、しかし起きてから対処するのでは遅い、という領域。コードのレビュー、設計のレビュー、要件のレビュー。AI時代、ここが最大のボトルネックになる。なぜか。AIで開発が速くなった結果、客観×予防の領域はさらに自動化が進みました。客観×回復もSREのプラクティスが浸透して、対応速度が上がっている。主観×回復は、起きてから対処なので、AIが速いことの恩恵が小さい。残った主観×予防が、相対的に最も時間を食う領域として目立ってきます。つまりこうです。AI時代のエンジニアリングの主戦場は、主観×予防の領域をいかに小さくするかです。人間がレビューしなければならない事柄を減らす。人間がレビューしなければならない事柄について、レビューの認知負荷を下げる。これが、現代のアーキテクチャの中心的な課題だと考えています。道具は何があるか。私がよく使う発想を並べます。Bounded Contextで、境界に予測不能性を囲うAIの最大の弱点は、コンテキストが広がりすぎたときに何を考慮すべきか分からなくなることです。コードベース全体を見ながら判断しろ、と言われると、関係ないコードに引きずられて、変な実装を出してくる。これに対する答えは、線を引くことです。1つのドメインモデルが意味を持つ範囲を、地面に線として引く。「この線の内側で完結する変更」と指示できるなら、AIは予測可能な振る舞いをします。線の外には触らない。線の内では、そのドメインの言葉だけで考える。業界の語彙では Bounded Context（境界づけられたコンテキスト）と呼ばれている発想です。境界は、AIへの命令の解像度を決めます。「商品検索を実装して」より「在庫管理コンテキストの商品検索を実装して」のほうが、出てくるコードの精度が大きく違います。境界が引かれていない巨大な泥団子のコードベースでは、AIは迷子になり、人間のレビュー負荷も上がる。境界がレビューを軽くするのです。境界にはやる範囲だけでなく、意図的に触らない範囲も含める。「在庫の判断はここまで、注文側のテーブルには触らない」「決済の整合性は別レイヤーで担保するので、ここでは扱わない」。書かないと、AIは気を利かせて越境してきます。境界を引くということは、内側の規定だけでなく、外側を「意図して扱わない」と宣言する行為です。そして、境界違反の検出は人手レビューでなく、依存関係チェッカーやアーキテクチャテストに任せる。機械が境界の番人になる形にしないと、半年で境界は溶けます。Always-Valid Domain Modelで、不正な状態を作れなくする要件のうち「この値は0以上」「この日時は過去」「この組み合わせは不可能」のような制約は、ドキュメントに書くのではなく、型の中に閉じ込めるのが私の好みです。私はある時期から、ドメインの値を生のintやStringで持ち回るのをやめました。在庫数をu32で渡し回していた頃、マイナス在庫のテストケースを書き忘れて本番に届かせたことがある。値は型で囲い、コンストラクタを閉じる。ドメインオブジェクトは、常に正当な状態でしか存在できないように設計します。設計思想というより、自分が同じ事故を二度起こさないための、もう少し卑近な防衛です。具体的には、コンストラクタを公開しない。代わりに Stock::new(count: u32) のようなファクトリで、不正な値を弾く。エラーを返すか、専用の型 NonNegative<u32> を要求する。型システムが「0以上の在庫数」という要件を強制する形です。こうすると、AIがコードを書くとき、不正な状態を作れません。型エラーになる。型がレビュアーになる。私はこれを「型がレビューしてくれる要件」と呼んでいて、ドキュメントで書くより遥かに強力だと考えています。Type Firstで、型から書き始めるAIに実装を任せるときは、関数の本体より先に型シグネチャを置きに行くようになりました。「この関数はこういう入力を受け取り、こういう出力を返す。エラーはこの型」。これを先に書いて、本体は AI に書かせる。これをやるようになって、3つの効果が出ました。1つ目は、要件の表明です。型は、関数が何を扱うかの宣言です。fn search(query: SearchQuery) -> Vec<InStockProduct> と書けば、「在庫がある商品しか返さない」という要件が型に現れている。型を読めば要件が読める。2つ目は、AIへの制約です。型に合わない実装を書こうとすると、コンパイルエラーになる。AIは型に合わせるしかない。プロンプトで「在庫切れを除外して」と指示するより、型で InStockProduct を要求するほうが確実です。3つ目は、レビューの省力化です。型が正しければ、本体の細部のレビューは軽くなる。型エラーが出ない時点で、要件の大枠は満たされている。レビュアーは「実装の細部」ではなく「型の選択」だけを見ればいい。ただし、Type Firstには適用範囲があります。プロジェクト初期の探索フェーズでは、型を先に書くと逆に動けなくなる。型を決めるためには、ドメインがある程度固まっている必要があるからです。ドメインが固まる前に型を書くと、書いた型がすぐ陳腐化する。だから私は、Type Firstをドメインが固まった後の規律として運用しています。先ではなく、後。順序が逆だと効かない。壊せるコードと壊せないコードを分ける書き込みと読み込みでは、レビューの重さが違います。これに気づいたのは、深夜の差し戻しが続いた時期です。書き込みのバグは、データを壊す。戻せない。読み込みのバグは、画面が崩れるだけで、再描画すれば直る。両者を同じ関数の中に混ぜると、レビュアーは1行ごとに「これは壊せる側か」を毎回判定することになる。これが疲れる。だから、書き込み側と読み込み側を、ファイル・ディレクトリ・クラスの層で分ける。書き込み側のPRは厳密に見て、読み込み側のPRは速く通す。レビューの強度を、人の良心ではなく構造で決める。AIが書いたコードを本番に通す勇気は、こういう非対称な防衛線の引き方から生まれます。業界の語彙では、これに近いパターンを CQRS（書き込みと読み込みを別経路に分ける設計）と呼んでいます。私が実感しているのは経路の分離そのものより、「壊せるコードと壊せないコードを別物として扱う」という重さの非対称のほうです。Event Sourcingで、事実を残すAIの書いたコードを本番で動かして問題が起きたとき、起きたことを後から再現できることが重要です。Event Sourcingは、システムで起きた事実をすべてイベントとして記録する設計です。注文が入った時刻。在庫が減った瞬間。配送が始まったタイミング。すべてイベントとして残る。これがあると、AIが書いたコードに不審な振る舞いがあっても、イベントログから再構成できます。なぜその状態になったかを追跡できる。説明責任の基盤になる。全部のドメインにEvent Sourcingを敷くのは過剰です。私は、お金が動くところと、後から「なぜそうなったか」を顧客に説明する義務がある領域だけに絞っています。以前、それ以外の領域、たとえば検索のクリックログを「念のため」とイベント化しかけて、ストレージとスキーマ進化のコストで詰まったことがありました。事実は残ったが、誰も読まなかった。読まれない事実は、ただのコストです。逆に、決済と在庫の変動については、過去にDBの状態だけ見て「なぜマイナス在庫になったか」が再現できず、顧客への謝罪文を書けなかった夜があります。あの夜以降、Event Sourcingは「全部のために」ではなく「説明責任が発生する境界に絞って」入れる、という基準に変わりました。AI時代、コードは速く生まれて速く変わります。コードを読んでも何が起きたか分からない瞬間が来る。そのときに残るのは、コードではなくイベントログです。「事実が残っている」という設計の安心感は、AIで書いたコードを本番に出す勇気の源にもなる。主観×予防を最小化する、ということこれらのパターンに共通しているのは、人間の主観的判断が必要な領域を、構造で減らしているということです。要件をドキュメントで書くと、レビュアーはドキュメントとコードを照合する主観的作業をすることになります。要件を型に焼き込めば、コンパイラが照合してくれる。要件をテストに焼き込めば、CIが照合してくれる。要件を境界に焼き込めば、Bounded Contextが照合してくれる。これは、要件を消滅させているわけではありません。要件を表現する場所を変えているだけです。ドキュメントから、型・テスト・境界・スキーマ・イベントへ。表現が変わると、検証が機械化できる。検証が機械化できると、人間の主観レビューが減る。主観×予防を最小化する努力は、AIの精度を上げる努力ではなく、AIの間違いを機械が捕まえる仕組みを増やす努力です。AIは間違える。間違えていいから、間違いが本番に届かない構造を作る。これがAI時代のアーキテクチャの仕事だと考えています。そして、ここで効いているのは結局、要件をドキュメントではなく構造に焼き込んでいるかです。ドキュメントは人間しかレビューできない。構造はコンパイラとCIがレビューしてくれる。要件の置き場所を変えることが、AI時代のアーキテクチャの起点になります。結合の強度を選ぶ要件群同士の関係を設計するときに、私が頭の中で使っている目盛りがあります。結合の強さを4段階で見る、というものです。最も濃いのが侵入的な結合で、片方の実装の細部にもう片方が直接依存している状態。プライベートな内部状態を覗き込む、内部の関数を直接呼ぶ。これが一番避けたい形で、片方の小さな変更がもう片方を壊します。そこから一段薄くなると機能的な結合で、共通のビジネスドメイン知識を共有する関係。「在庫管理ドメイン」の中で、複数の要件が同じ前提を共有する。同じドメインの中なら自然な結合で、避けるべきものではありません。ドメインモデルの一部が境界を越えて共有されると、モデル的な結合になります。注文ドメインの「商品」が、在庫ドメインの「商品」と同じモデルを使う。境界を越えるたびに、両者が同期して変わる必要が出てきます。最も疎なのが契約的な結合で、最小限の入出力契約だけで繋がる関係です。「このAPIに { product_id, quantity } を渡せば、{ ordered_id } が返る」。中身がどう実装されているかには互いに干渉しない。結合の選び方には、原則があります。頻繁に一緒に変わるものは近くに、別々に変わるものは離して置く。同じドメインの中の要件群は機能的な結合で構わない。違うドメイン同士は契約的な結合に下げる。これを誤ると、同じ変更を10箇所に伝搬させる必要が出てくる。要件を動くものに散らすとき、散らした先同士の結合をどの強度に設計するかが、認知負荷を決めます。散らせばよいわけではなく、散らした後の結合の強度を選ぶ。これも要件設計の中心的な仕事です。非機能要件は互いに干渉する、トレードオフのネットワーク要件の話の中で、機能要件はわりと書きやすい。「ユーザーが商品を検索できる」「在庫切れ商品を含めない」のように、できる／できないが判定できるからです。難しいのは非機能要件のほうです。性能、可用性、セキュリティ、保守性、ユーザビリティ、移植性、拡張性。これは「何をするか」ではなく「どれだけうまくやるか」を規定します。ドキュメントで書こうとすると、すぐに「セキュアでなければならない」「使いやすくなければならない」のような検証不能な文に縮みます。検証可能性の節で書いた「悪い言葉」のリストは、非機能要件の周りで特に多発します。非機能要件を扱うとき、私が頭に置いている前提が1つあります。すべての設計はトレードオフだ、ということ。何かを良くすれば、何かが悪くなる。トレードオフが見えていないなら、存在しないのでなく、まだ気づいていないだけです。「全部を最高水準にしたい」と要件を並べる依頼者がいたら、その要件群はそのままで実現不可能だ、と早い段階で伝える必要があります。全部を最大化した設計は、たいてい途中で沈みます。トレードオフを「最適化の問題」と捉えると、必ず詰まります。性能とセキュリティは、同じ単位では測れない。比較できないものを比較する局面で、人間が最後に1つを選ぶ。その選択を計算で逃げないことが要件設計者の仕事だと思っています。過去に、性能と監査ログ保持期間のトレードオフで詰まったことがあります。両方を最大化したい依頼が来て、計算すると予算が1.7倍になる。どちらを諦めるかを決めずに3週間引きずり、最後はインシデントが起きてから保持期間を渋々延ばす形で決まりました。決められなかったから決まらなかったのではない。決めなかった結果、事故が私たちの代わりに決めた。それだけの話です。選んだ理由は、計算ではなく言葉にしか残せません。だから優先順位は、数字より、選んだ場面の言葉として書き残しておく。そして非機能要件には、もう1つ厄介な性質があります。互いに干渉することです。互いに干渉する、何かを上げれば何かが落ちる具体的に見ます。あるAPIに対して、暗号化を厚くするほど、応答時間は悪化します。鍵交換、ハンドシェイク、暗号化処理、復号処理、それぞれにCPU時間がかかる。セキュリティの要件を強くすると、性能の要件が圧迫される。可用性とコストも同じです。冗長化を増やすほど可用性は上がるが、インフラコストが増える。ログ保持期間を長くするほど監査と可観測性は上がるが、ストレージコストとプライバシー懸念が増える。非機能要件は孤立して書いても意味がない、というのが、この性質から来ます。1つの要件を強めると、別の要件が弱まる。だから、要件を1つずつ最適化するのではなく、要件群全体のバランスを設計する必要が出てきます。「セキュリティ:高、性能:高、可用性:高、コスト:低」と並べた仕様書は、現実で実現不可能です。ドキュメントは要件を独立に書くのへ向いていますが、現実の要件は独立していない。ドキュメントのフォーマットそのものが、トレードオフを見えにくくしている、という言い方もできます。過去のソフトウェア事故を振り返ると、相互作用の見落としが繰り返し出てきます。ある小売チェーンで起きた大きな情報漏洩は、空調管理システムのリモートアクセス権限が、店舗ネットワーク全体への侵入経路になった事例として知られています。空調側の要件は「外部から監視できる」、店舗ネットワーク側の要件は「店舗業務に必要」、それぞれは妥当でした。問題は、両者を繋いだときに発生する経路を、どちらの要件も明示していなかったことです。ある宇宙探査機の事故は、単位系が異なるサブシステム間で、変換せずに数値を渡してしまった事例として知られています。各サブシステムの要件は内部では正しかった。問題は、相互運用性の要件が、両側のどこにも明示されていなかったことです。これらに共通するのは、個別の要件は妥当でも、要件群の境界や相互作用が要件として書かれていなかった、という構造です。要件は単独では完全になれない。要件と要件の間にある関係を、別の要件として書く必要があります。ここで、信頼性を例に1つ補足しておきます。信頼性の要件で私が大事にしているのは、「壊れないシステム」でなく「壊れても続くシステム」を要件にすることです。コンポーネントの故障そのものは避けられません。ハードディスクは壊れる。ノードはダウンする。ネットワークは切れる。これらが起きたとき、システム全体が止まるかどうかは別の問題です。コンポーネントの故障を直ちにサービスの停止へ転化させない設計を要件として書く。これは性能や可用性の単純な数値目標とは違う、構造的な要件です。要件文には、「故障が起きたらどう振る舞うか」「どこまでの故障なら許容するか」「復旧の前と後で何が保たれるか」を書きます。これがあると、設計時に冗長化や分散の判断ができる。書かないと、AIは「正常系で動く」コードしか書きません。暗黙的な要件こそ、書き漏らしやすい非機能要件を扱っていてよく感じるのは、書かれている要件より、書かれていない要件のほうが事故を起こす、ということです。要件には、明示されるものと、明示されないものがあります。「ユーザー数の目標は1万人」「応答時間は1秒以内」のように、依頼者が明確に語る要件は、書類に乗ります。これは明示的な要件です。一方、「データの整合性は崩れないこと」「セキュリティ侵害があったときに被害が広がらないこと」「障害から速やかに復旧できること」など、誰も口に出さなくても暗黙に期待されている要件があります。これが暗黙的な要件です。暗黙的な要件は、依頼者にとっては「言うまでもない」ものです。だからヒアリングしても出てきません。「セキュリティはどうしますか」と聞かれて初めて、ようやく考え始める。あるいは、聞かれても「もちろん大事です」で終わる。具体的な水準は、依頼者の頭の中にすら無いことが多い。ただ、暗黙的な要件は満たされなかったときに最もダメージが大きい性質を持っています。「セキュリティが破られた」「データが壊れた」「規制に抵触した」。これは表に出ていなかった分、起きたときに「言ってなかったじゃないか」「いや、言うまでもないでしょう」という応酬になりやすい。要件発見の仕事の半分は、暗黙的な要件を引き出して、明示的にすることだと考えています。「言うまでもない」と思われている前提を、こちらから具体化する。「セキュリティが大事ということは、たとえばこの侵入経路を防ぎたい、ということですか」と問い返す。暗黙のものを言葉にした瞬間、初めて要件として扱えるようになります。ここでもAI時代の影響が出ます。AIには、暗黙の前提が一切伝わりません。書類に書かれていない要件は、AIにとっては存在しないのと同じです。書かれていなかった暗黙の要件を、AIは無頓着に踏み抜いていきます。暗黙の要件をどれだけ明示化できるかが、AIに渡す要件の質を決めます。優先順位を選ぶ、何を取り、何を捨てるか互いに干渉する要件群を扱うときに、私が現場で守っている規律が、「最も重要な3つ」に絞る、ということです。Top 3 を決める作業の裏で、自分が実際にやっているのは、何本かの軸の中から1本を選び取ることです。要件の評価には、裏に何本もの軸が並んでいます。目的にどれだけ合っているか。制約をどれだけ満たしているか。そもそも技術的に作れるか。当該組織のスキル分布で運用し続けられるか。保守性や可観測性のような品質を確保できるか。短期と長期で時間効果がどう違うか。不確実性をどう引き受けるか。既存アーキテクチャと整合するか。関係者が合意できるか。学術用語では分野ごとに別の名前で呼ばれますが、現場で衝突している軸は、たいていこのあたりに収まります。軸が複数並んでいるとき、判断とは「全部の軸を最高水準にする」ことではなく、いまの場面でどの軸を中心に据えるかを1つ選ぶことです。私はこの「中心に据える軸」を「主軸」と呼ぶことにしています。Top 3 に絞るという作業の本体は、主軸を1つ立てて、二次条件を2つほど添える、という形に置き換えられます。主軸はその場面で他より優先するために立てるもので、二次条件は主軸ほど重視しないが最低水準を切らないために置くものです。1軸に絞った瞬間に他を捨てていいわけではない——この区別を持たないと、主軸を立てた瞬間に別の軸が無防備に置き去りにされます。ここで起きやすい失敗は、主軸そのものの取り違えです。障害対応の最中に長期の理想設計を始めてしまう。基幹システムの刷新なのに、短期の実装容易性だけで選んでしまう。セキュリティの問題なのに、UX の都合で判断を曲げる。PoC なのに本番運用の品質を要求する。本番機能なのに PoC の速度感で書く。技術領域は違いますが、構造は1つに収束します。本来の主軸ではない軸を、主軸として採用してしまっている。主軸を間違えると、二次条件をどれだけ精緻に満たしても判断は失敗します。これは設計の事故というより、判断の手前で起きている事故です。ただし、規制業界のように複数の軸が一次制約として同列に並ぶ場面では、1本に絞る規律そのものが成立しません。金融や医療や安全制御の領域では、性能と監査と可用性の3本がいずれも下限を切れない。そこは別の世界として切り分けています。私が書いているのは、絞れる側の話です。絞れる場面で絞らないことの代償は大きく、絞れない場面で無理に絞ることの代償も同じくらい大きい。まず自分の場面が絞れる側か絞れない側かを判定するのが、主軸を立てる手前の仕事です。そして、ここで効いてくるのが第1部から書いてきた「どの軸を大事にしているかを言葉にする」規律です。書いていないと、依頼者は依頼者の主軸で、エンジニアはエンジニアの主軸で、AIエージェントは平均的にもっともらしい主軸で、それぞれ違うものを「最重要」として扱います。主軸は明文化されない限り、関係者ごとに別々の軸が裏で走る。Top 3 を要件文に書くという作業は、その背後で主軸を1つ選んで明文化する作業の表側です。依頼者にヒアリングすると「セキュリティ・性能・可用性・保守性、すべて重要」と並べてきます。すべてが最重要なら、トレードオフの場面で何を譲って何を守るかが決まらない。そこで、「最も重要でない要件を順番に外していく」問いを使います。「すべて重要というのは分かりました。では、もし1つだけ妥協するとしたら、どれですか」。これを繰り返してTop 3に絞ります。なぜ3つか。10個並べるとどれが大事か分からなくなる。1つだけだと、他が無視される。3つは、優先順位を識別しつつ、トレードオフの会話を可能にする数です。絞れなかった残りの要件は、Top 3 に従属する形で扱う。Top 3を満たした上で対応し、矛盾するなら他のほうを諦める。これで、設計時の判断が一貫します。要件のうちには、1つでは不十分で、複数の組み合わせで初めて意味を持つものもあります。たとえば「金融取引の信頼性」は、性能・可用性・データ整合性・監査可能性が同時に成立しないと達成されない。こういう複合的な要件は、Top 3の枠の中で、複数の構成要素として分解して展開します。ハード目標とソフト目標を分ける優先順位を選んだ後、達成の性質が違う2種類の要件があることに気づきます。ハード目標は、達成したかしていないかが二値で判定できる要件です。「APIは規定のステータスコードしか返さない」「決済処理はべき等である」「個人情報は暗号化されてDBに保存される」。満たしていないなら、それはバグで、出荷はできません。ソフト目標は、満たし方の度合いがある要件です。「応答が速い」「使いやすい」「保守しやすい」。これは度合いで評価されるので、複数の軸の組み合わせで「十分」を定義する必要があります。「応答が速い」を、「初回ロード」「操作後の応答」「検索結果の返却」のように、シーンごとに切り分けて、それぞれに許容範囲を置く。ソフト目標で注意したいのは、完全達成を目指さないことです。「無限に速く」を追求すると他の要件が崩れます。最低限・目標・意欲的目標・願いの4段階で書き、「目標」のラインに到達したらそこから先はトレードオフで決める。完璧を狙わず、十分を狙う。優先順位を要件文に書く決めたTop 3と段階記述は、要件文に明示的に書きます。書かないと、AIは平均的なバランスを採用してきます。「応答時間（最優先）、セキュリティは認証突破耐性を確保（次点）、コストは年間予算内（制約）」のように、序列を明示する。優先順位の記述はドキュメントのままで構いませんが、書いた優先順位が守られているかを、定期的にコードで確認する必要があります。性能が悪化したのは、誰かが「セキュリティを少し強めた」結果かもしれない。優先順位の監視は、コードのメトリクスとアーキテクチャ判断を結ぶ運用の話で、第3部の「要件を殺すな」に繋がる主題です。要件は単独では完成しない。互いに干渉するからこそ、要件群全体を1つの設計対象として扱う規律が要ります。これがアーキテクチャの仕事の中心にあると、私は考えています。優先順位は時間で反転する優先順位は決めて終わり、ではありません。プロジェクトの段階が変わると、Top 3 そのものが入れ替わります。立ち上がった直後の段階では、機能要件が支配的です。「使えるものを早く出す」が最優先で、性能や保守性は後回しでも、たいてい問題になりません。利用者がまだ少なく、コードもまだ新しい。しかし、利用者が増えてくると、スケーラビリティが前面に出てきます。1台のサーバーで足りていたものが、複数台に分散しないと処理できなくなる。データベースの設計も、初期の素朴な作りでは間に合わなくなる。同じ要件群でも、優先度の重みが変わります。そして、システムが安定して稼働する時期に入ると、今度は保守性と運用効率が支配的になります。コードを直すコスト、運用するチームの人件費、障害対応の負担。これらが日々のコストになり、機能追加よりも長期の保守性が重要になる。つまり、非機能要件の優先順位はプロジェクトの寿命に沿って変わるのです。「Top 3」も、立ち上げ時のTop 3と、その後のTop 3は、たいてい同じではありません。ここで気をつけたいのは、過去のTop 3に固執すると、現在のシステムを壊すことです。立ち上げ時に「最優先は速さ」と決めて作った要件群を、利用者が桁で増えても同じまま運用すると、保守性が崩壊して身動きが取れなくなる。要件は時間の中で重みづけを更新する必要があります。これを書きながら、自分が一度しくじった場面を思い出します。立ち上げ期に「速さ最優先」で組んだAPI群を、利用者が10倍になった後も同じ重みで運用していました。新機能を出すたびに障害が増える。原因は1つの機能ではなく、Top 3そのものが古びていたことでした。保守性が下から3番目だった当時の優先順位は、その時点では正しかった。正しかったが、半年経って正しくなくなったのに、誰も書き換えを提案しませんでした。私自身も、最初に決めた表を信じすぎていた。書き換える勇気、と書きましたが、勇気というよりTop 3を定期的に見直すリズムを運用に組み込まなかったことが、本当の失敗だったと思います。半年に一度、Top 3を声に出して読み直す。読み直して違和感が出たら、その場で書き換える。要件文の更新を、機能追加とは別の運用イベントとして時間割に置く。これがないと、過去のTop 3は静かに現在のシステムを侵食します。これは要件の運用の話で、第3部の「要件を殺すな」と地続きです。書いた瞬間の優先順位を、永遠に守る必要はない。ステージが変わったら、Top 3も書き換える。書き換える勇気が、要件を生き続けさせます。「いつ・誰が・どこで・何が・どう・どのくらい」を埋める非機能要件を曖昧でなくするとき、私が頭の中で必ず埋めるようにしている問いがあります。いつ・誰が・どこで・何が・どう振る舞い・どのくらいの数で、を1つの場面として書き切る、というものです。「セキュリティが高い」「使いやすい」「速い」のような単語は、書き手と読み手で違う絵が見えています。でも、1つの具体的な場面として書き切ると、絵が揃う。たとえば「セキュリティが高いこと」を、この6つの問いで書き直すと、こうなります。外部の悪意ある攻撃者が、認証情報を試行する攻撃を、本番運用中の認証APIに対して行ったとき、システムは攻撃を検知してアカウントをロックし、規定時間内に管理者に通知する。「いつ＝攻撃の試行が起きたとき」「誰が＝外部の悪意ある攻撃者」「どこで＝本番運用中の認証API」「何が＝システム」「どう＝攻撃を検知してアカウントをロック」「どのくらい＝規定時間内に通知」。6つの隙間が全部埋まっているから、これは実装とテストに直接落ちます。書き慣れていないと、最初は不自然に長く感じます。でも、長いのではなく、今までが短すぎただけです。「セキュリティが高い」の1行は、6つの問いを全部読み手の想像に投げていた。だから書き手と読み手で別の絵が見えていた。6つを書き切ると、読み手は想像する余地がなくなります。AIに渡しても、書かれた通りに実装してくれます。「セキュリティが高い」では、AIは何を作ればいいか分かりません。「規定時間内に通知」と書いてあれば、その時間に合わせるコードを書きます。非機能要件を6つの問いで書き切ることは、「要件を動くものにする」上での重要な実践です。曖昧なドキュメントを、実装可能な場面に変換する。フォーマットが要件の質を縛るなら、フォーマットを変えるところから始める、というのが私の現在地です。書き切った後、もう一段やります。書いた数値を監視ダッシュボードに繋げる。応答時間、エラー率、認証試行回数、ロック発動頻度。要件文に書いた数字がそのまま監視に流れるようにすると、要件と現実のズレを機械が検知してくれます。手で四半期レビューする運用は、要件の半分が見落とされて終わる。要件の数字は、書いて終わりではなく、計測のキーに変える。これも、人手で見守る部分を機械に渡していく姿勢の1つです。領域ごとに要件の重みは違うここでもう1つ書いておきたいのが、システム全体に同じ要件群を当てはめない、という発想です。1つのプロダクトの中でも、領域ごとに必要な品質要件は違います。顧客向けの画面はスケーラビリティと可用性が支配的、バックオフィスはセキュリティとデータ整合性が支配的、検品ツールは保守性とテスト容易性が支配的。これらを全部1つの巨大なモノリスに収めると、必要な要件群が衝突して、どれも中途半端になります。逆に、領域ごとに必要な要件群が揃っている範囲を切り出して、それぞれを独立して動かす設計に分けると、要件群の衝突が減ります。マイクロサービスや境界づけられたコンテキストの分け方は、技術的な分割というより、要件群が違うところに線を引く作業です。要件発見の段階で「ここから先は別の要件群が支配する」と気づければ、設計の境界も自然に決まります。要件は均質でない。プロダクトの中の領域ごとに、要件の重みが違う。この見方を持つと、Top 3もシステム全体ではなく、領域ごとに別のTop 3になります。要件発見の段階で見抜けると、後段の設計が楽になる視点です。AIを組み込んだシステムでは、品質要件のカテゴリが増えるここまで挙げてきた品質要件は、ソフトウェア一般の話です。AIや機械学習を組み込んだシステムには、従来の分類に収まらない品質要件が乗ります。私自身は規制領域の現場には普段いないので、ここから先は射程外の話を含みますが、要件発見の対象が広がっているという感覚だけは、輪郭を共有しておきたい。判断の根拠を人間が説明できるか。出力が特定の属性で偏らないか。AIの判断のトレーサビリティが法令の要求を満たすか。これらは性能や可用性のような数値目標とは違う種類の要件で、検証可能性をそのまま当てはめにくい。「説明可能であること」をテストに落とすには、「説明とは何か」「誰にとって説明可能か」を先に決めなければなりません。定義そのものが、新しい要件発見の対象になっているということです。古典の要件工学の道具立ては今も使えますが、対象が増えた分、扱いの幅も広げる必要が出ている。これも、要件を動くものへ散らす話と無関係ではありません。説明可能性を「ドキュメントに書いた」で済まさず、推論ログ・モデルカード・監査経路として動くものに焼き込めるかが、AI時代の要件運用の延長線上にあります。おわりに要件をドキュメントだけで保とうとして、何度か失敗してきました。理由は、たぶん私の能力でも、書き方の流儀でもない。ドキュメントという媒体そのものに、毎日読まれ続けるための仕組みが備わっていないのだと、今は思っています。要件をドキュメントだけに置くのを、私はやめました。やめてみて初めて、ドキュメントは何のためにあるのかが少しずつ見えてきた気がします。ドキュメントは補助で、本体は動くもの。CLAUDE.md、skill、テスト、型、lint、CI、PRテンプレート。これらに分散して焼き込まれた要件だけが、毎日読まれ、毎日検証され、毎日少しずつ良くなっていく。決定論で表現できるものは、決定論で守る。確率論で扱うべきものは、プロンプトとガイドラインで導く。両者を混ぜずに、人間がレビューすべき領域を最小化するのが、要件設計の中心的な仕事になりつつある気がしています。そして、主観×予防の領域を構造で減らす。Bounded Contextで予測不能性を囲い、型で不正な状態を排除し、テストで振る舞いを縛る。AIは間違えます。たぶん、これからもしばらく間違えます。間違えていいから、間違いが本番に届かない構造を作る。それがAI時代のアーキテクチャの仕事だと、今のところ考えています。要件を動くものへ散らすことの効用は、3つあると思っています。1つは、要件が腐りにくくなること。動くものは、変更すれば壊れて気づける。2つ目は、AIが直接読めること。ドキュメントはコンテキストを圧迫しますが、コードや型・テストはAIにとって読みやすい形で要件を伝えられる。3つ目は、レビューが軽くなること。決定論側で守られた要件は、人間によるレビューを必要としない。ただし、これは万能ではありません。動くものへの分散は、それ自体が保守コストを生みます。テストが古びる。lintルールが時代遅れになる。CLAUDE.mdの記述が現状と乖離する。散らした分だけ、剪定の責任も増える。剪定をどう回すかは、第3部の話です。おい、要件を動くものにしろ。ドキュメントに閉じ込めるな。型・テスト・CI・PRへ散らせ。動くものに刻まれた要件だけが、たぶんAIに飲み込まれず残ります。第3部「おい、要件を殺すな」では、動き始めた要件を時間の中でどう保つかを書きます。要件は決定の蓄積であること、AIには時間軸が見えないこと、要件を回し続ける運用、人間に残る責任、そして実務で守るべき最低ラインについて、順番に。syu-m-5151.hatenablog.com参考書籍ソフトウェア要求　第3版作者:カール ウィーガーズ；ジョイ ビーティ日経BPAmazonソフトウェア見積り　人月の暗黙知を解き明かす作者:スティーブ マコネル日経BPAmazonはじめよう！ 要件定義 ～ビギナーからベテランまで作者:羽生章洋技術評論社Amazonだまし絵を描かないための－－要件定義のセオリー作者:赤俊哉リックテレコムAmazonこんにちは！要件定義①【情報活用とデータベース編】 ビジネス ✕ IT企画作者:羽生 章洋技術評論社Amazonバイブコーディングを超えて ―AI時代を生き抜く開発者の未来作者:Addy Osmani,佐藤 直生（翻訳）オーム社Amazonドメイン駆動設計をはじめよう ―ソフトウェアの実装と事業戦略を結びつける実践技法作者:Vlad Khononovオーム社Amazonソフトウェア設計の結合バランス 持続可能な成長を支えるモジュール化の原則 (impress top gear)作者:Vlad KhononovインプレスAmazonソフトウェアアーキテクチャの基礎 第2版 ―エンジニアリングに基づく体系的アプローチ作者:Mark Richards,Neal Ford,島田 浩二（翻訳）オーム社Amazon作る、試す、正す。　アジャイルなモノづくりのための全体戦略作者:市谷 聡啓ビー・エヌ・エヌAmazon]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[`NonZero<T>` で「ゼロではない」を型に乗せる]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/05/013955</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/05/013955</guid>
            <pubDate>Mon, 04 May 2026 16:39:55 GMT</pubDate>
            <content:encoded><![CDATA[はじめにゼロ割り、ゼロサイズ確保、ゼロインデックス。「ゼロでない数値」を期待する場面はあちこちにあります。これを runtime チェックではなく 型 で保証できる仕組みが NonZero<T> です。Rust 2024 edition で書き味がさらに素直になりました。これまでは NonZeroUsize, NonZeroU32 のように個別の型名を使っていました。それがジェネリック表記の NonZero<usize>, NonZero<u32> に統一されています。Rust 1.79 で NonZero<T> が安定化し、2024 edition では標準的な書き方になりました。まず動くコードページング計算で「ページサイズはゼロにできない」を型で保証する例。use std::num::NonZero;fn page_count(total_items: usize, page_size: NonZero<usize>) -> usize {    total_items.div_ceil(page_size.get())}fn main() {    let page_size = const { NonZero::new(50).expect("50 != 0") };    let pages = page_count(1003, page_size);    println!("pages = {pages}");}実行結果は次のとおりです。pages = 21page_count の引数が NonZero<usize> なので、ゼロを渡すコードはそもそも書けません。NonZero::new(0) は None を返し、 0 から NonZero を構築する手段はありません。ゼロ割りは関数まで到達する前にコンパイラが弾きます。NonZero<T> の作り方NonZero<T> を作るには3パターンあります。use std::num::NonZero;// 1. リテラルから const block で構築 (推奨)let n1 = const { NonZero::new(50).expect("50 != 0") };// 2. 動的な値から作る (失敗するかもしれない)fn try_build(x: usize) -> Option<NonZero<usize>> {    NonZero::new(x)}// 3. unsafe で「絶対ゼロでない」を契約として宣言let raw: usize = some_runtime_value();let n3 = unsafe { NonZero::new_unchecked(raw) }; // ゼロを渡したら UB通常は (1) か (2)。(3) の new_unchecked は契約違反すると Undefined Behavior なので、本当に必要な性能クリティカルな場面でだけ使います。(1) の const { ... } ブロックは Rust 1.79 で安定化しました。コンパイル時に panic しないことを保証する 構文です。NonZero::new(50) はコンパイル時に評価され、.expect(...) も const context で動くので、もし 0 を書いていたらコンパイルエラーになります。runtime panic にはなりません。ジェネリック表記のメリットこれまで個別の型名 (NonZeroUsize, NonZeroU32, ...) でも NonZero<T> でも同じ機能でしたが、ジェネリック表記のほうが2点で勝ります。コードの一貫性複数の整数型を扱うジェネリック関数で、 NonZero<T> のほうが自然に書けます。use std::num::NonZero;fn safe_div<T>(a: T, b: NonZero<T>) -> Twhere    T: std::ops::Div<Output = T>        + From<NonZero<T>>  // 仮想的な制約。実際にはより複雑になる        + Copy,{    a / T::from(b)}ジェネリックパラメータ T をそのまま NonZero<T> に持っていけます。 個別の型名 (NonZeroUsize / NonZeroU32) で書こうとすると、関数を整数型ごとに複製する羽目です。標準ライブラリの構造と一致Option<T>, Result<T, E>, Vec<T> のように、 標準ライブラリの type wrapper はジェネリックです。NonZero<T> も同じ形にしておくほうが頭の整理がつきます。「ゼロでない」以外のバリデーション型NonZero<T> は標準ライブラリだけが提供できる「組込み型に対する制約」です。同じ発想を自分の型に適用したいなら、 newtype でラップして smart constructor を書くのが定石です。#[derive(Debug, Clone, Copy)]pub struct PositiveAmount(u64);#[derive(Debug, thiserror::Error)]#[error("amount must be positive")]pub struct NotPositive;impl PositiveAmount {    pub fn new(amount: u64) -> Result<Self, NotPositive> {        if amount == 0 {            Err(NotPositive)        } else {            Ok(Self(amount))        }    }    pub fn get(self) -> u64 {        self.0    }}fn charge(account: &mut Account, amount: PositiveAmount) {    account.balance += amount.get();}# struct Account { balance: u64 }「同じ u64 だけど、 PositiveAmount を経由しないと charge に渡せない」という制約を型で表現できます。これは NonZero<u64> とほぼ同じ発想ですが、自分のドメインに合わせた型を作れる柔軟性があります。いつ NonZero<T> を使うか「ゼロを許容しない」が引数のセマンティクスとして重要な場面で使います。代表例を挙げます。ページサイズ、バッチサイズ、ロット数配列のインデックス (1-based)アロケーションサイズスレッド数 / ワーカー数逆に「カウンタ」「累積値」のような「ゼロから始まる数値」では NonZero を使う場面ではありません。実用上の覚え方は 「もしこの値がゼロだったら関数の意味が壊れるか」 を考えることです。ゼロで意味が壊れる引数は NonZero<T>、ゼロでも意味のある引数は普通の整数。サイズ最適化地味なメリットですが、Option<NonZero<T>> は T と同じサイズです。None を 0 で表現する最適化が効きます。use std::num::NonZero;use std::mem::size_of;fn main() {    println!("{}", size_of::<u32>());                     // 4    println!("{}", size_of::<NonZero<u32>>());            // 4    println!("{}", size_of::<Option<u32>>());             // 8 (タグ + 値)    println!("{}", size_of::<Option<NonZero<u32>>>());    // 4 (None = 0 で表現)}Option<NonZero<u32>> は Option<u32> より小さい。これは NonZero の値が 0 を取れないので、0 を使って None を表現できるためです。コレクションに大量に詰めるとメモリ効率に効きます。まとめNonZero<T> で「ゼロでない数値」を型で保証できるゼロを渡すコードは関数まで到達する前にコンパイラが弾く2024 edition の慣用句では NonZero<usize> のようなジェネリック表記を使うリテラルから作るときは const { NonZero::new(...).expect(...) } で runtime panic 回避自分のドメイン制約には newtype + smart constructor で同じ発想を適用できるOption<NonZero<T>> はサイズ最適化が効く引数のドキュメントに「page_size must be > 0」と書く代わりに、 NonZero<usize> で受ければそれが型で表現されます。書くドキュメントが減って、 runtime チェックも減って、 ミスも減ります。関連std::num::NonZero: https://doc.rust-lang.org/std/num/struct.NonZero.htmlRust 1.79 release notes (NonZero<T> 安定化): https://blog.rust-lang.org/2024/06/13/Rust-1.79.0/]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[`thiserror` と `#[non_exhaustive]` で SemVer に強いエラー型を作る]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/04/235804</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/04/235804</guid>
            <pubDate>Mon, 04 May 2026 14:58:04 GMT</pubDate>
            <content:encoded><![CDATA[はじめにRust で「ライブラリのエラー型をどう設計するか」は永遠のテーマです。素朴な答えは2つあります。アプリケーションなら anyhow で済ませるライブラリなら thiserror で型付きエラーを返すこれは2026年でも変わりません。この記事は「ライブラリ向け」のほうの設計を、 #[non_exhaustive] を組み合わせて SemVer 互換を壊さない形で書く方法を扱います。まず動くコードuse std::num::ParseIntError;#[derive(Debug, thiserror::Error)]#[non_exhaustive]pub enum LoadError {    #[error("io: {0}")]    Io(#[from] std::io::Error),    #[error("parse: {0}")]    Parse(#[from] ParseIntError),    #[error("empty input")]    Empty,}pub fn parse_first_number(text: &str) -> Result<i64, LoadError> {    let line = text.lines().next().ok_or(LoadError::Empty)?;    let n = line.trim().parse::<i64>()?; // ParseIntError → LoadError 自動変換    Ok(n)}fn main() -> Result<(), LoadError> {    println!("{}", parse_first_number("42\nrest")?);    println!("{:?}", parse_first_number(""));    println!("{:?}", parse_first_number("not-a-number"));    Ok(())}実行結果は次のとおりです。42Err(Empty)Err(Parse(ParseIntError { kind: InvalidDigit }))Cargo.toml に必要なものは1行だけです。[dependencies]thiserror = "2"thiserror 2.0 は 2024年末リリースで、Rust 2021/2024 両方で動きます。thiserror の役割thiserror::Error を #[derive] すると以下が自動で実装されます。std::error::Error (=Rust の標準エラートレイト)std::fmt::Display (#[error("...")] 属性で書式を指定)From<T> (#[from] 属性を付けたフィールドから自動変換)書く側は Debug を #[derive] して、各 variant に #[error("メッセージ")] を付けるだけ。#[derive(Debug, thiserror::Error)]pub enum MyError {    #[error("bad request: {reason}")]    BadRequest { reason: String },}{reason} のような placeholder には struct のフィールド名や、 タプル variant のインデックス ({0}, {1}) を使えます。#[from] でエラー変換を ? で連鎖させる#[from] は ? 演算子で自動変換するための From 実装 を生成します。これにより、内部エラーを呼び出し側に透過的に伝搬できます。#[derive(Debug, thiserror::Error)]pub enum LoadError {    #[error("io: {0}")]    Io(#[from] std::io::Error),}fn read() -> Result<String, LoadError> {    let s = std::fs::read_to_string("config.toml")?; // io::Error → LoadError::Io    Ok(s)}? を書くだけで std::io::Error が LoadError::Io(...) に変換されます。手書きの map_err は要りません。注意点があります。同じ型を #[from] 付きで複数の variant に置くことはできません。「io::Error を Io と Disk の両 variant に入れたい」ならどちらかは手書きの From 実装にします。#[non_exhaustive] で SemVer を守るここが重要です。公開ライブラリのエラー型に #[non_exhaustive] を付けると、利用者の match 文に _ ワイルドカード arm を強制できます。// あなたのライブラリ側#[derive(Debug, thiserror::Error)]#[non_exhaustive]pub enum LoadError {    #[error("io: {0}")]    Io(#[from] std::io::Error),    #[error("empty input")]    Empty,}利用者側の match はこうなります。// 利用者側match error {    LoadError::Io(_) => println!("io error"),    LoadError::Empty => println!("empty"),    _ => println!("other"),  // ← この `_` がないとコンパイルエラー}_ を強制することで、あなたのライブラリが 将来 variant を追加しても利用者のコードは壊れません。これが #[non_exhaustive] の最大のメリットです。新しい variant の追加は SemVer 上「破壊的変更」になりえますが、#[non_exhaustive] 付きなら minor バージョン (1.0 → 1.1) で追加できます。注意点を2つ挙げます。ライブラリ内部 (同じ crate 内) では _ を強制されない。crate 外からの match だけ制約されるstruct でも使える (新フィールド追加に強くなる)ライブラリ vs アプリの使い分けここで最初の二択に戻ります。 用途  選択  理由  公開ライブラリ  thiserror で型付きエラー  利用者がエラー種別ごとにハンドリングできる  アプリ (CLI, サーバー)  anyhow で型消去  エラーを上位に投げて main で ? するだけで済む  内部 lib (同じ workspace 内)  thiserror 推奨  後で公開する可能性 / テストでエラー種別を assert したい 「anyhow を使ったコードを公開ライブラリにする」のが一番こじれます。利用者は anyhow::Error を見せられても何もできません。最初から thiserror で型を切るほうが、後で楽になります。エラー設計のチェックリスト公開ライブラリのエラー enum を書くとき、以下を確認します。[ ] #[derive(Debug, thiserror::Error)] が付いている[ ] #[non_exhaustive] が付いている[ ] 各 variant に #[error("...")] で人間向けメッセージがある[ ] 内部から伝搬したいエラーに #[from] を付けている[ ] エラーメッセージに値が含まれる場合、placeholder で書式指定している[ ] Result<T, MyError> を返す関数のドキュメントに、どの variant がいつ発生するか書いてある最後の項目はドキュメント側ですが、利用者にとっては最も大事です。「Foo::do_it は MyError::Empty を返すことがあります」と書いておくと、利用者は match で網羅できます。まとめライブラリのエラー型は thiserror::Error を #[derive] する#[error("...")] で Display 出力を、#[from] で From 実装を自動生成#[non_exhaustive] を付けると、利用者の match に _ arm が強制され、 minor バージョンで variant を追加できるアプリ層は anyhow で型消去するのが楽。公開ライブラリは thiserrorthiserror + #[non_exhaustive] の2つは、新規ライブラリ初日に入れる組み合わせとしてほぼ正解です。関連thiserror クレート: https://docs.rs/thiserror/#[non_exhaustive] (Rust reference): https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[`if let` chain — Rust 1.88 / 2024 edition で実用フェーズに入った]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/03/152036</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/03/152036</guid>
            <pubDate>Sun, 03 May 2026 06:20:36 GMT</pubDate>
            <content:encoded><![CDATA[はじめにRust 1.88 (2025年) で if let chain が安定化されました。2024 edition のコードでは積極的に使えます。 && で if let 条件を連結できるようになり、ネストした if let を平らに書ける構文です。短い変更ですが、効果は大きく出ます。何を解決するかOption が複数あって、すべて Some だったときだけ何かしたい、という場面。素直に書くとネストになります。fn first_premium(items: &[String]) -> Option<&str> {    if let Some(first) = items.first() {        if let Some(rest) = first.strip_prefix("premium-") {            return Some(rest);        }    }    None}「最初の要素を取り出して、それが premium- で始まっていれば残りを返す」という処理ですが、 if let が2段ネストして本筋が見えにくいです。if let chain での書き方2024 edition / Rust 1.88 以降ならこう書けます。fn first_premium(items: &[String]) -> Option<&str> {    if let Some(first) = items.first()        && let Some(rest) = first.strip_prefix("premium-")    {        return Some(rest);    }    None}&& で let パターンを連結します。「最初の let が成功して、かつ次の let も成功する」が満たされたときだけ if 本体に入ります。ポイントを整理します。各 let で取り出した変数 (first, rest) は 後続の条件と if 本体の両方で使える普通の bool 条件と混ぜられる: if let Some(x) = a && x > 0 && let Some(y) = b { ... }失敗したら全体の if は false 扱いネストが消えて、guard 条件が一直線に読めます。let-else との使い分け似た用途で let-else もあります。fn first_premium_letelse(items: &[String]) -> Option<&str> {    let Some(first) = items.first() else { return None; };    let Some(rest) = first.strip_prefix("premium-") else { return None; };    Some(rest)}両者は使い分けがあります。 構文  向いている場面  if let chain  「成功したらある処理、失敗したら何もしない」: 条件が満たされた時だけ実行する  let-else  「失敗したら関数を抜ける」: ガード句として使い、以降の処理を平らに続ける first_premium の例だと、「失敗時は None を返すだけ」なので let-else のほうが少しシンプルでしょう。一方、「成功したらこの if 本体に入って、入らなければ後続処理に進む」のような continue 的な用途では if let chain が自然です。具体例を見ます。fn process(events: &[Event]) {    for event in events {        if let Event::Click { target } = event            && let Some(url) = target.url()            && url.starts_with("http")        {            println!("opening {url}");        }        // 失敗したら次の event に進む    }}# enum Event { Click { target: Target } }# struct Target;# impl Target { fn url(&self) -> Option<&str> { None } }ループの中で「3条件すべて満たしたら何かする」を1つの if で書けます。let-else でループから continue するより自然です。bool 条件と混ぜるif let chain は普通の bool 条件と混ぜられます。fn handle(message: &Message, allowlist: &[String]) {    if let Some(sender) = &message.sender        && allowlist.contains(sender)        && let Some(body) = &message.body        && !body.is_empty()    {        println!("ok: {sender} -> {body}");    }}# struct Message { sender: Option<String>, body: Option<String> }let の取り出しと、普通の比較条件 (allowlist.contains, is_empty) が混ざっても、左から右に評価されます。途中で false なら短絡します。何が嬉しいのか主な3点を挙げます。ネスト削減: if let の2段3段ネストが消える意図が線形に読める: 「これとこれが、さらにこれが揃ったら」が左から右に並ぶ副条件を挟みやすい: let の合間に普通の bool 条件を入れられる特に3は地味に便利で、「if let Some(x) と書いてから if x > 0 のために中で if を1個増やす」というパターンが消えます。注意: 安定化は 1.88if let chain が stable になったのは Rust 1.88 です。それより古い rustc ではコンパイルが通りません。Cargo.toml に rust-version = "1.88" 以上を書いていなければ、CI 用に最低でも 1.88 で動かす必要があります。[package]edition = "2024"rust-version = "1.88"  # if let chain を使うなら2024 edition そのものは 1.85 から有効ですが、if let chain のために MSRV を 1.88 に上げる、という関係になります。簡単に試すCargo.toml:[package]name = "if-let-chain-demo"version = "0.1.0"edition = "2024"rust-version = "1.88"src/main.rs:fn main() {    let items = vec![        "premium-coffee".to_string(),        "tea".to_string(),    ];    if let Some(first) = items.first()        && let Some(rest) = first.strip_prefix("premium-")    {        println!("found premium: {rest}");    } else {        println!("nothing premium");    }}cargo run で found premium: coffee が出ます。まとめif let chain は 2024 edition / Rust 1.88 で実用フェーズに入った&& で let パターンと bool 条件を連結できる取り出した変数は後続の条件と if 本体の両方で使えるlet-else は「失敗したら抜ける」、if let chain は「成功したら何かする」で使い分け使うときは MSRV を 1.88 以上に揃える関連Rust 1.88 release notes (if let chains 安定化): https://blog.rust-lang.org/releases/RFC let_chains: https://rust-lang.github.io/rfcs/2497-if-let-chains.html`let-else` で早期 return を素直に書く - じゃあ、おうちで学べる]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[`let-else` で早期 return を素直に書く]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/02/120443</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/02/120443</guid>
            <pubDate>Sat, 02 May 2026 03:04:43 GMT</pubDate>
            <content:encoded><![CDATA[はじめにRust の let-else 構文は、Rust 1.65 (2022年) で安定化された機能です。古くからある機能ですが、2024 edition のクリーンなコードベースでは多用されます。clippy の manual_let_else ルールも 2024 では使うことが推奨されます。「Option が None だったら関数を抜ける」「Result が Err だったらエラーで早期 return する」というパターンを、ネストせず書ける構文です。何を解決するかOption<T> から T を取り出して処理を続けたい、という場面はたくさんあります。素直に書くとこうなります。fn label(maybe_name: Option<&str>) -> Option<String> {    if let Some(name) = maybe_name {        Some(format!("hello, {name}"))    } else {        None    }}これは match でも同じです。fn label(maybe_name: Option<&str>) -> Option<String> {    match maybe_name {        Some(name) => Some(format!("hello, {name}")),        None => None,    }}短ければこれで困りませんが、続きの処理が長くなるとネストが深くなります。fn process(maybe_name: Option<&str>, maybe_age: Option<u32>) -> Option<String> {    if let Some(name) = maybe_name {        if let Some(age) = maybe_age {            // 本来やりたい処理がインデントの底に沈む            Some(format!("{name} is {age}"))        } else {            None        }    } else {        None    }}ネストが2段、3段と増えると、何を達成したいのかコードからすぐに読み取れなくなります。let-else の書き方let-else は「let で値を取り出すが、失敗したら else ブロックでスコープから抜ける」と書けます。fn label(maybe_name: Option<&str>) -> Option<String> {    let Some(name) = maybe_name else {        return None;    };    Some(format!("hello, {name}"))}ポイントを整理します。let Some(name) = maybe_name で名前を取り出そうとする失敗 (None) なら else ブロックに入るelse ブロックは 必ず関数から抜ける (return, break, continue, panic) 必要がある成功した場合、name は変数として以降のスコープで使えるネストの段数が減って、本来やりたい処理 (=フォーマットして返す) が一直線に書けます。ネスト解消の効果先ほどの2段ネスト版を let-else で書き直してみます。fn process(maybe_name: Option<&str>, maybe_age: Option<u32>) -> Option<String> {    let Some(name) = maybe_name else { return None; };    let Some(age) = maybe_age else { return None; };    Some(format!("{name} is {age}"))}3行になりました。「失敗ケースは早めに弾いて、本筋のコードを平らに書く」 という guard pattern が自然に表現できます。Result でも同じResult でも同じ書き方ができます。エラー伝搬は ? のほうが短いですが、エラー型を変換したい時は let-else のほうが明示的です。#[derive(Debug)]enum AppError {    InvalidInput(String),}fn parse_pair(s: &str) -> Result<(i32, i32), AppError> {    let Some((left, right)) = s.split_once(',') else {        return Err(AppError::InvalidInput(format!("missing comma: {s}")));    };    let Ok(left) = left.trim().parse::<i32>() else {        return Err(AppError::InvalidInput(format!("not an int: {}", left.trim())));    };    let Ok(right) = right.trim().parse::<i32>() else {        return Err(AppError::InvalidInput(format!("not an int: {}", right.trim())));    };    Ok((left, right))}fn main() {    println!("{:?}", parse_pair("1, 2"));    println!("{:?}", parse_pair("hello"));    println!("{:?}", parse_pair("1, x"));}実行結果はこうなります。Ok((1, 2))Err(InvalidInput("missing comma: hello"))Err(InvalidInput("not an int: x"))これを ? で書こうとすると、 String を AppError に変換する From 実装を毎回足す必要が出てきます。let-else ならエラーメッセージを箇所ごとに直接書けるので、テスト書く前のプロトタイプ段階では let-else のほうが速いです。何を else ブロックに書けるかelse ブロックは「never 型を返す」必要があります。具体的には次のような選択肢です。return ...breakcontinuepanic マクロloop で永久ループ上記を返す関数 (例: std::process::exit)普通の値を返すと「else ブロックは関数から抜ける必要があります」とコンパイラに怒られます。else ブロック内で何か値を計算してから抜けるのは OK です。let Some(name) = maybe_name else {    eprintln!("name is missing");    return None;};clippy::manual_let_else で機械検出するCargo.toml に1行入れると、let-else で書ける箇所を clippy が指摘してくれます。[lints.clippy]manual_let_else = "warn"新規プロジェクトでは入れておくのを勧めます。if let { ... } else { return ... } を見つけて警告してくれるので、自然に let-else の習慣がつきます。いつ使わないかlet-else がいつでも正解というわけではありません。以下のケースは別の構文のほうが自然です。成功時と失敗時で同じ後処理がある: match のほうが両分岐を並べて書けて読みやすいOption を別の値に置き換えたいだけ: unwrap_or / unwrap_or_else / map_or で十分Result の Err をそのまま伝搬したいだけ: ? で1文字// これは let-else よりシンプルlet port = config.get("port").unwrap_or("8080");// これは let-else より ? が自然fn read() -> Result<String, std::io::Error> {    let content = std::fs::read_to_string("config.toml")?;    Ok(content)}let-else は 「失敗時は関数から抜けるが、成功時は以降長く処理を続ける」 ときに最も光ります。まとめlet-else はネストを減らして guard pattern を書ける構文else ブロックは return / break / panic! などで必ず抜ける必要があるOption / Result のどちらにも使えるclippy::manual_let_else = "warn" で書き直し候補を機械検出できる失敗パスが短く、成功パスが長い場面で最大の効果関連let-else RFC: https://rust-lang.github.io/rfcs/3137-let-else.htmlclippy manual_let_else: https://rust-lang.github.io/rust-clippy/master/index.html#manual_let_else]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[`unwrap()` を書きそうになったときの選択肢8つ]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/05/01/195115</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/05/01/195115</guid>
            <pubDate>Fri, 01 May 2026 10:51:15 GMT</pubDate>
            <content:encoded><![CDATA[はじめにRust で Option や Result から値を取り出すとき、つい .unwrap() を書いてしまう場面があります。動くし短い。だから書く。だけどそのコードは、エラー時にスタックトレースなしで panic します。本番で踏むと 「どこで何の理由で死んだのか」 がわからない事故になります。この記事は、.unwrap() を書きたくなった瞬間の代替案を8個並べます。状況に応じて選んでください。前提: なぜ unwrap() は 良くないか.unwrap() の問題は3つです。panic メッセージが「called \Option::unwrap()` on a `None` value`」のような汎用文言で、原因の手がかりがないどんな理由で None / Err が起きたかドキュメントに残らない後で読み返したときに「ここは絶対 None にならないと信じている」のか「面倒で書いた」のか区別がつかないCargo.toml で clippy::unwrap_used = "deny" を入れると unwrap() がコンパイルエラーになります。これを入れたうえで以下の選択肢から選ぶのが、2024 edition のクリーンなコードベースでの基本姿勢です。選択肢 1: ? で伝搬する呼び出し側にエラーを投げ返すのが一番素直です。Result 型を返す関数の中で ? を1文字書くだけ。fn read_config() -> Result<String, std::io::Error> {    let s = std::fs::read_to_string("config.toml")?;    Ok(s)}この用途で unwrap() を書く理由は基本ありません。? の方が短く、エラー型を保ったまま呼び出し側に渡せます。選択肢 2: expect("理由") で意図を残す「絶対 None にならないと自分は信じているが、もし None になったら panic で気付きたい」時は .expect("理由") を使います。let port = std::env::var("PORT").expect("PORT must be set in environment");panic 時のメッセージが「PORT must be set in environment」になるので、ログを見た人がすぐ原因にたどりつきます。unwrap() で死んだ場合の「called Option::unwrap on a None value」とは情報量が桁違いです。expect_used lint を warn にしておくと、expect を使った箇所が coverage 的に見えます。それでも unwrap よりは1段マシ。選択肢 3: unwrap_or でデフォルト値を返す「None / Err なら fallback 値を使う」だけのケース。unwrap_or 系がきれいに収まります。let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());let first_word = "  hello world".split_whitespace().next().unwrap_or("(none)");let count: u32 = config.get("count")    .and_then(|s| s.parse().ok())    .unwrap_or(0);unwrap_or (静的なデフォルト) と unwrap_or_else (クロージャで遅延評価するデフォルト) の使い分けがあります。unwrap_or(expensive_call()) だと None じゃなくても evaluate されて重いので、計算が必要な fallback は unwrap_or_else 一択です。選択肢 4: ok_or / ok_or_else で Option を Result に変える「Option<T> を ? で伝搬したい」場面。ok_or で Result<T, E> に持ち上げます。#[derive(Debug)]enum Error {    Missing(&'static str),}fn read_port(map: &std::collections::HashMap<&str, String>) -> Result<u16, Error> {    let raw = map.get("port").ok_or(Error::Missing("port"))?;    raw.parse().map_err(|_| Error::Missing("port"))}unwrap() で「絶対あるはず」と書く代わりに、 ok_or で「無かった場合のエラー」を表明する。読み手にとって意図がはるかに明確です。選択肢 5: let-else でガードを書くOption を ? で投げ返すには大げさだが、ネストはしたくない、という場面。 let-else でガード句を書きます。fn label(maybe_name: Option<&str>) -> Option<String> {    let Some(name) = maybe_name else {        return None;    };    Some(format!("hello, {name}"))}「失敗時は早期 return、成功時は以降長く処理を続ける」スタイルが書けます。unwrap() で取り出してから後続処理を書くより、意図が線形に読めます。選択肢 6: if let chain で複数条件を平らにする複数の Option がすべて Some だったときだけ何かしたい、という場面。 Rust 1.88 / 2024 edition の if let chain。fn first_premium(items: &[String]) -> Option<&str> {    if let Some(first) = items.first()        && let Some(rest) = first.strip_prefix("premium-")    {        return Some(rest);    }    None}「全部 Some なら本体に入る」が一直線で読めます。選択肢 7: match で全 variant を網羅するエラー variant ごとに違う対処をしたい時は、 match で網羅します。match parse_port(input) {    Ok(port) => println!("port = {port}"),    Err(ParsePortError::TooLarge) => eprintln!("port out of range"),    Err(ParsePortError::NotANumber(s)) => eprintln!("not a number: {s}"),}unwrap() でひとまとめに panic させると、原因によって違う出口を作れません。これは エラー variant 単位でログレベルや復旧戦略を変えたい 場合に重要です。選択肢 8: 設計を見直す最も強力な選択肢。「そもそも Option / Result を返す必要があったのか」を問い直すことです。// before: ゼロ割りを runtime で検出fn page_count(total: usize, page_size: usize) -> Option<usize> {    if page_size == 0 { None } else { Some(total.div_ceil(page_size)) }}// after: NonZero<usize> でコンパイル時に「ゼロでない」を保証fn page_count(total: usize, page_size: std::num::NonZero<usize>) -> usize {    total.div_ceil(page_size.get())}引数の型を絞れば Option を返す必要がそもそもなくなります。unwrap() を書きたくなったら 「型でこの不安を消せないか」 を一度考える価値があります。これが Rust ならではの強み。チェックリストunwrap() を書きそうになったとき、上から順にチェックします。Result を返す関数なら → ?絶対あるはずだが panic で気付きたい → .expect("理由")None / Err なら fallback でよい → unwrap_or / unwrap_or_elseOption を Result に変えたい → ok_or / ok_or_else失敗時は早期 return → let-else複数 Option を AND で扱う → if let chainエラー variant ごとに分岐したい → match「そもそも Option を返す必要があるか」 → 型を絞るCargo.toml に unwrap_used = "deny" を入れて lint を強制すれば、「とりあえず .unwrap()」を CI 段階で止められます。コードレビューの負担も減ります。まとめ.unwrap() は panic 時の情報量が乏しく、意図がコードに残らない antipattern多くの場合 ? / expect / unwrap_or / ok_or / let-else / if let / match のどれかで書ける最終手段は「型を絞って Option を返す必要をなくす」設計の見直しclippy::unwrap_used = "deny" を Cargo.toml に入れて、 CI で強制する関連clippy unwrap_used: https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_usedstd::option::Option API: https://doc.rust-lang.org/std/option/enum.Option.htmlstd::num::NonZero: https://doc.rust-lang.org/std/num/struct.NonZero.html]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[A2A protocol における Remote Agent 間の分散 tracing — OpenTelemetry + Cloud Trace で可視化する]]></title>
            <link>https://sreake.com/blog/a2a-distributed-tracing-with-otel-and-cloudtrace/</link>
            <guid isPermaLink="false">https://sreake.com/blog/a2a-distributed-tracing-with-otel-and-cloudtrace/</guid>
            <pubDate>Thu, 30 Apr 2026 09:39:58 GMT</pubDate>
            <content:encoded><![CDATA[はじめに Sreake 事業部の井上 秀一です。私は Sreake 事業部にて、 SRE や生成 AI に関する Research & Development を行っています。 近年、 LLM を活用した AI  […]The post A2A protocol における Remote Agent 間の分散 tracing — OpenTelemetry + Cloud Trace で可視化する first appeared on sreake.com | 株式会社スリーシェイク.]]></content:encoded>
            <author>Sreake</author>
        </item>
        <item>
            <title><![CDATA[決定論 vs 確率論：Gemini 3 FlashとTF-IDFを組み合わせた「法規判定エンジン」の構築]]></title>
            <link>https://speakerdeck.com/shukob/jue-ding-lun-vs-que-lu-lun-gemini-3-flashtotf-idfwozu-mihe-waseta-fa-gui-pan-ding-enzin-nogou-zhu</link>
            <guid isPermaLink="false">https://speakerdeck.com/shukob/jue-ding-lun-vs-que-lu-lun-gemini-3-flashtotf-idfwozu-mihe-waseta-fa-gui-pan-ding-enzin-nogou-zhu</guid>
            <pubDate>Thu, 30 Apr 2026 04:00:00 GMT</pubDate>
            <content:encoded><![CDATA[https://genai-users.connpass.com/event/391381/複雑な例外規定や、政令・省令への外部参照（ポインタ）が張り巡らされた法規の解析は、確率論で動くLLM単体では「最も失敗しやすい」領域の一つです。本セッションでは、「古典NLP（決定論）」と「生成AI（確率論）」を垂直統合したハイブリッド判定エンジンの設計思想とソースコードを徹底解説します。【こんな方におすすめ】生成AI（LLM）のハルシネーション対策に苦労しているエンジニアRAG（検索拡張生成）の精度が上がらず、構造的な解決策を探している方法律や規約など「1文字の揺らぎも許されない」ドメインでのAI活用に興味がある方「最新のAIさえあれば古い技術は不要」という風潮に一石を投じたい方【学べること】法的効力の5層スタック： 憲法から条例までをITインフラとして解釈する視点決定論的ガードレール： TF-IDFとコサイン類似度を用いた、揺るがない根拠の引き当て方リーガル・コンパイラ： 法令XMLから論理フラグ（例外・準用）を抽出する前処理技術プロンプト注入の極意： LLMの推論空間を物理的に制限し、論理推論マシンとしてのみ機能させる方法]]></content:encoded>
            <author>Shu Kobuchi</author>
        </item>
        <item>
            <title><![CDATA[完結編・Anthropic Claude (Sonnet 4.6 / Opus 4.7) を加えて、5機種で自転車青切符ベンチマークを総括する]]></title>
            <link>https://shu-kob.hateblo.jp/entry/2026/04/29/001542</link>
            <guid isPermaLink="false">https://shu-kob.hateblo.jp/entry/2026/04/29/001542</guid>
            <pubDate>Tue, 28 Apr 2026 15:15:42 GMT</pubDate>
            <content:encoded><![CDATA[はじめにこのシリーズもとうとう4本目です。Flash編：Gemini 3 Flash 単体は 5/7（71%）、決定論的 NLP（条文AST + TF-IDF cos類似度）を前段に挟むハイブリッドで 6/7（86%）。Pro編：Gemini 3.1 Pro 単体でも 5/7 で頭打ち、最後の一歩で Flash と同じ方向に倒れる。Gemma3 編：ローカル gemma3:4b は単体 0/7、ハイブリッドでも条文を注入されてもなお論理を逆向きに倒す。ここまでで、フロンティア Gemini ファミリ（Flash/Pro）、ローカル小型モデル（Gemma3:4b）でひと通りの検証が終わっていました。今回は Anthropic Claude を加えます。具体的には Claude Sonnet 4.6（claude-sonnet-4-6@default）と Claude Opus 4.7（claude-opus-4-7@default）を、Vertex AI 経由で同じベンチマークに通しました。結論を先に書きます。単体ベンチの数字（5/7）はフロンティア4機種で完全に並ぶ。 Flash・Pro・Sonnet・Opus の単体正答率は同じです。ハイブリッド構成での「失敗の質」は割れる。 Layer 1（卒論側）が条文を取り逃したとき、Gemini 系は 「データに無い → 違反を問えない → 合法」と推論ジャンプするのに対し、Claude 系は 「該当条文なし、判定不能」と素直に降参します。法規制用途で見たときに、これは決定的な差です。Layer 1 を挟むとフロンティア LLM 間の差はほぼ消える。 Sonnet と Opus はハイブリッド構成下で判定内容が同一でした。Layer 1 が天井になっており、Layer 2 のモデル力は支配要因ではありません。以下、データと観察を順に書きます。実験条件ベンチマーク本体：前回までと完全同一の 7 問（src/benchmark/flash_only_judge.py の TEST_CASES）ハイブリッド側のシステムプロンプト・Layer 1 動作・反則金テーブル：すべて据え置きAnthropic 側の経路：Vertex AI （anthropic[vertex] SDK の AnthropicVertex クライアント、region="global" エンドポイント）認証：既存の Gemini 用の Google Cloud ADC をそのまま流用。Anthropic API キーは取得していませんモデル ID：claude-sonnet-4-6@default / claude-opus-4-7@default推論設定：adaptive thinking 有効、temperature は Opus 4.7 では使えないため両モデルとも未指定実装側の差分はそれほど大きくなく、src/llm/anthropic_client.py で client.models.generate_content(model, contents, config) という Gemini 互換のインタフェースに AnthropicVertex をラップしただけです。config.py の get_llm_client(model) がモデル名のプレフィックスで Vertex AI Gemini / Ollama / Vertex AI Anthropic を振り分けます。コマンドラインも --model claude を増やしただけ。python -m src.main --benchmark --model claude   # Sonnet または Opus（CLAUDE_MODEL 定数で切替）python -m src.main --hybrid --model claudeここで Vertex AI 経由を選んだ理由は実務寄りです。Gemini と同じ GCP プロジェクトに課金を寄せられて、ADC 認証も既存運用が流用できます。Anthropic 公式 API（ANTHROPIC_API_KEY）への切り替えはアダプタの 1 行差し替えで済むようにしてあります。結果サマリ — 5機種総括すべて同一 7 問、同一プロンプト、同一 Layer 1。単体ベンチ（LLM 丸投げ、Layer 1 なし） モデル  単体正答率  自動ハルシ検知  隠れ捏造（手動観測）  平均応答時間  Gemini 3 Flash  5/7 (71%)  0/7  TC-005 で第117条の2の2 の細部捏造  約 7〜9 秒  Gemini 3.1 Pro  5/7 (71%)  0/7  TC-005 で「第117条の2の2第11号」「第125条第1項」など細部捏造  約 13〜22 秒  Claude Sonnet 4.6  5/7 (71%)  0/7  TC-006 で「5,000円（施行令第41条の2）」を捏造  約 30〜65 秒  Claude Opus 4.7  5/7 (71%)  0/7  TC-006 で「6,000円」を捏造、TC-007 で「施行令別表第六」を作話  約 5〜10 秒  Gemma3:4b（ローカル）  0/7 (0%)  1/7  7問すべて条文・金額作話、桁外れの 10万円・3万円も出る  約 13〜33 秒 「自動ハルシネーション検知 0/7」の数字を Claude 4 機種で素直に信じてはいけません。自動チェッカーは「期待条文番号が回答中に含まれているか」しか見ないので、判定と条文番号が当たっていれば、金額が捏造でも ✓ になります。Sonnet TC-006 の "5,000円" も、Opus TC-006 の "6,000円" も、その隙間を抜けて見逃されていました。それでも、捏造の出方には系統差が見えます。Flash / Pro：判定そのものは合っているが、条文の「項・号」レベルの細部を作話する（例：第117条の2の2 → 第117条の2の2第1項第3号、第125条第1項）Sonnet：金額そのものを作話し、もっともらしい施行令第41条の2 を典拠に挙げるOpus：金額を作話し、施行令別表第六を典拠に挙げるGemma3:4b：そもそも実在しない条番号を引いてくる、金額が制度の枠外（10万円超）Flash / Pro はベース知識として「青切符の枠（3,000〜12,000円）」は把握しており、その中の数字を捏造するに留まります。Claude 系は青切符制度の存在と金額レンジは把握していますが、典拠の引き方で過剰に詳細を捏造する癖があります。ハイブリッド（Layer 1 注入あり）評価ルブリック：判定（合法/違反）、根拠条文、反則金、すべて期待値と一致 = ✓、判定と条文は合うが金額抜け or 不一致 = △、判定そのものが誤り = ✗。 モデル  TC-001  TC-002  TC-003  TC-004  TC-005  TC-006  TC-007  完全 ✓  本質的 ✗  合計時間  Gemini 3 Flash  ✓  △ 金額抜  ✗ 合法と誤判定  ✓  ✓  ✓  ✓  5/7  1  629 秒  Gemini 3.1 Pro  ✓  △ 金額抜  ✗ 合法と誤判定  ✓  ✓  ✓  ✓  5/7  1  147 秒  Claude Sonnet 4.6  ✓  △ 金額抜  △ 「判定不能」  ✓  ✓  ✓  ✓  5/7  0  226 秒  Claude Opus 4.7  ✓  △ 金額抜  △ 「該当条文なし」  ✓  ✓  ✓  ✓  5/7  0  54 秒  Gemma3:4b  ✗  ✗  ✗  ✗  ✓  ✓  ✓  3/7  4  249 秒 完全正答数だけ見ると、フロンティア 4 機種が同点 5/7。差が出るのは 「TC-003 で何が起きたか」 の一点です。ハイブリッドの分かれ目：TC-003「ながらスマホ」をどう外したかこのシリーズの全編で繰り返し主役になっている TC-003、すなわち「自転車でスマートフォンを手に持って運転」のケースです。期待される正解は 第71条第5号の5、反則金 12,000円。Layer 1 はここで毎回同じ取り逃しをします。クエリ中の口語「スマホ」と、条文中の「無線通信のために用いられる装置」のような硬い表現が TF-IDF レベルで噛み合わず、cos 類似度の上位に挙がってくるのは以下の3条です。#1 cos=0.1421 → 第六十三条の九 （自転車の制動装置等）#2 cos=0.1135 → 第六十三条の十 （自転車の検査等）#3 cos=0.1126 → 第六十三条の三 （自転車道の通行区分）第71条第5号の5 は、Layer 1 が引いてこられないまま Layer 2 に渡されます。プロンプトには「該当する反則金情報なし」と注入されます。ここで、LLM 4 機種の挙動がはっきり割れます。Gemini 3 Flash（ハイブリッド）{  "judgement": "合法",  "article": "該当なし",  "fine": "対象外（提供された条文データに禁止規定がないため）",  "reasoning": "提供された条文データ（第六十三条の三、第六十三条の九、第六十三条の十）は、自転車の通行区分、制動装置（ブレーキ）や反射器材の整備義務、および警察官による検査について定めたものであり、自転車運転中にスマートフォンを手に持つ行為を禁止する規定が含まれていません。また、反則金データにおいても該当する情報が「なし」とされているため、提示された条文の範囲内では違反とは判定されません。"}「条文に禁止規定がない → 違反とは判定されない → 合法」。論理として一貫しているように見えますが、これは法規制用途では最悪の False Negative です。実際には違反である行為について「合法ですよ」と回答してしまっています。Gemini 3.1 Pro（ハイブリッド）{  "judgement": "合法",  "article": "該当条文なし",  "fine": "該当する反則金情報なし",  "reasoning": "提供された条文データ（第六十三条の九、第六十三条の十、第六十三条の三）には、自転車運転中のスマートフォン等の使用を禁止する規定が含まれていません。したがって、提供された情報の範囲内では違反を問う根拠がなく、反則金についても該当する情報がありません。"}Pro でも結論は同じ「合法」。reasoning の言い回しはやや慎重に「提供された情報の範囲内では」と限定していますが、最終 judgement は Flash と同じです。「Pro に上げれば判定が変わる」は、ここでも成立しませんでした。Claude Sonnet 4.6（ハイブリッド）{  "judgement": "判定不能",  "article": "該当条文が提供データに含まれていない",  "fine": "判定不能（根拠条文が提供されていないため金額を回答できません）",  "reasoning": "提供された条文データは「自転車の制動装置等（第63条の9）」「自転車の検査等（第63条の10）」「自転車道の通行区分（第63条の3）」の3条文のみです。いずれも自転車の装備・通行区分に関する規定であり、スマートフォンを手に持ちながら運転する行為（携帯電話使用等）を直接規制する条文は提供データに含まれていません。…提供データ外の条文・金額を補完することは指示により禁止されているため、本質問に対して根拠ある判定を行うことができません。正確な回答のためには、携帯電話使用等に関する条文（一般的には道路交通法第71条等）のデータを含めた再照会が必要です。"}judgement フィールドに 「判定不能」 という、本来スキーマには無い値を入れて返しています。reasoning では更に踏み込んで 「一般的には道路交通法第71条等のデータを含めた再照会が必要」 と、Layer 1 の取り逃しを名指しで指摘しています。これは Anthropic 系のモデルで観測される顕著な傾向です：スキーマに合わせるよりも、回答できないことを正直に報告するほうを優先する。プロンプトには judgement は「合法」or「違反」と書いてありますが、Sonnet はこれを破ってでも「判定不能」と返しました。Claude Opus 4.7（ハイブリッド）{  "judgement": "違反",  "article": "提供された条文データには、自転車運転中の携帯電話使用（いわゆる「ながら運転」）を直接規律する条文は含まれていません。",  "fine": "提供された反則金データに該当情報なし（本パーサーが抽出した条文範囲では金額を特定できません）"}Opus は judgement フィールドに「違反」を入れ、article フィールドで「条文が提供されていない」と説明しました。Sonnet とは違ってスキーマは保っています。判定そのものは正しい方向（実際違反です）に倒しつつ、根拠の不足を正直に書く、という形です。4 機種を並べると「Layer 1 が条文を取り逃したとき、LLM はどう振る舞うか」は、ハイブリッド構成の安全性そのものです。 モデル  TC-003 の判定  失敗の質  Gemini 3 Flash  合法  False Negative（実は違反なのに「合法」）  Gemini 3.1 Pro  合法  同上  Claude Sonnet 4.6  判定不能  名指しで Layer 1 の取り逃しを指摘  Claude Opus 4.7  違反（根拠不足を併記）  判定方向は正しい、根拠は正直に降参 Gemma3:4b はそもそも「違反」と返すものの第63条の9（制動装置）を強引に当てはめるという別種の誤りで、ここに並べる意味は薄いので省略します。法規制ツールとしての安全性を Gemini と Claude で比べると、Layer 1 の穴が空いたときに「沈黙する Claude」と「合法と言ってしまう Gemini」という対比が出ました。これはユーザにとって意味が違います。前者ならユーザは「もう一度別の言い方で聞こう」「人に聞こう」となれる。後者ならユーザは「合法だ」と信じて違反行為に進みかねない。Sonnet vs Opus の差はどこに出たかハイブリッド構成下では、判定内容そのものは Sonnet と Opus で完全に同一でした。7 問とも同じ判定、同じ条文番号、同じ反則金額。Layer 1 が同じ素材を渡している以上、Layer 2 が Opus でも Sonnet でも同じ結論に着地します。差が出たのは応答時間と、思考の出方です。 軸  Sonnet 4.6  Opus 4.7  ハイブリッド合計時間  226 秒  54 秒  TC-001 単独時間  61 秒  9 秒  TC-007 単独時間  9 秒  4 秒  単体ベンチ合計時間  約 230〜260 秒  約 50〜60 秒 4 倍速で完走したのは Opus 4.7 のほうです。これは adaptive thinking が機能した結果だと思っています。Opus 4.7 では thinking: {type: "adaptive"} が常時オンで、モデルが「どれだけ考えるか」を毎ターン自分で決めます。今回のように Layer 1 が条文 AST と反則金テーブルを完全に揃えて渡しているケースでは、Opus は 「資料は揃っているので考える余地は少ない、整形だけする」 と判断したように見えます。Sonnet 4.6 は同条件で TC-001 に 61 秒考え込みました。Opus は同じ TC-001 を 9 秒で済ませています。「より高性能なモデルのほうが速く終わる」という、直感に反する結果が出ます。ただし、これは思考リソースをタスク難易度に動的配分できるかどうかの差なので、Layer 1 を外して LLM 単体で複雑な推論をさせると Sonnet と Opus の差は逆転する可能性があります。実際、単体ベンチでは Opus も Sonnet もほぼ同程度（5〜10 秒対 30〜65 秒で Opus が速いままでしたが、いずれもそれなりに考えた応答でした）。コスト面では Opus 4.7 の単価は Sonnet 4.6 の約 1.67 倍（$5 / $25 vs $3 / $15、いずれも 1M tokens あたり）ですが、今回は Opus が adaptive thinking で短時間に済ませたぶん、トークン消費は Sonnet より少なくなりました。実コストはタスクとモデルの組み合わせで逆転しうるので、単純に「Sonnet のほうが安い」とは言い切れない結果でした。単体 → ハイブリッドで何が変わったか（5機種横断）このシリーズ全体で見えてきたのは、「Layer 1 を挟むことで何が消え、何が残るか」がモデルクラスごとに違うという構造です。 モデル  単体での主な失敗  ハイブリッドで消えたもの  ハイブリッドに残ったもの  Flash  階層の平坦化、条番号の細部捏造  細部捏造  階層判断（TC-002）、Layer 1 取り逃し時の False Negative（TC-003）  Pro  同上（途中式は丁寧、最後で揺らぐ）  同上  同上（Flash と同じ穴）  Sonnet  金額の捏造（5,000円 ＋ もっともらしい典拠）  金額捏造  TC-002 の金額抜け、TC-003 で素直な降参  Opus  金額・典拠の細部捏造（別表第六 など）  金額捏造、別表参照  同上、ただし「違反」とまで判定はする  Gemma3:4b  条文ネットワーク自体を作話  一部のみ消える（TC-006/007）  注入された条文を読み損ねる致命的な誤り このシリーズの主張は 「決定論的 NLP で根拠を限定して注入すると、LLM の捏造は物理的に封じられる」 でした。フロンティア 4 機種では、これは概ね成立します。Sonnet と Opus でも、ハイブリッド構成では金額の捏造が消え、Layer 1 が引いた金額をそのまま反復します。Gemma3:4b では、注入された条文を逆向きに解釈する事例が残るため、Layer 1 だけでは詰めきれません（前回 Gemma3 編で詳述）。Claude を加えて新たに見えたのは、「Layer 1 が穴を開けたときの後始末」がモデル間で割れることです。これはシリーズ全体を通じて、Claude を回すまで気づけなかった差でした。所感Anthropic Claude をベンチマークに加えてみての観察を整理します。Vertex AI 経由の Anthropic クライアントは、Gemini と同じ ADC・同じ GCP プロジェクトで運用できる。 Anthropic 公式 API キーを別途取らずに済むのは、エンタープライズの請求一本化や監査の観点でかなり実用的でした。SDK は pip install 'anthropic[vertex]' の 1 行追加、コードはアダプタ層で完結、というところまで含めて運用しやすい構成でした。Layer 1 が天井になっている。 Sonnet と Opus がハイブリッドで完全同点だったのは、このプロジェクトの仮説（決定論的前処理が支配要因）の良い裏取りでした。Layer 2 のモデル力で稼げるのはレイテンシと、Layer 1 が穴を開けたときの後始末の質だけです。「失敗の質」がベンダーで割れる。 Gemini ファミリは Layer 1 の穴に対して「条文に無い → 規制されていない → 合法」と推論ジャンプし、Claude ファミリは「データに無い、判定不能」と降参する。この差は数字には出ない（5/7 は同じ）が、ユーザにとっては大きく違います。法務・コンプライアンス用途なら、安全側に倒れる Claude のほうが望ましく見えます。adaptive thinking は推論時間を短縮する方向にも効く。 Opus 4.7 が Sonnet 4.6 の 4 倍速で完走したのは意外でしたが、Layer 1 で資料が揃っている場合に「考える必要がない」と判断する挙動は、ハイブリッド構成と相性が良いと思っています。Sonnet を adaptive thinking で動かしても同じ短縮は起きなかったので、ここは Opus 4.7 固有の最適化が効いている可能性があります。自動チェッカーの粒度では Claude の捏造を取り逃す。 TC-006 の「5,000円」「6,000円」は条文番号一致だけ見ていると ✓ 判定になります。マッチャー側を judgement + article + fine の三軸で見る方向に拡張するのは別途の宿題として残りました。Layer 1 の TF-IDF 取り逃し（TC-003）は今回も残った。 「スマホ」と「無線通信のために用いられる装置」の語彙ギャップは、4 編通じて未解決です。Sentence-BERT などの意味埋め込みを Layer 1 に追加するか、用語辞書（口語 → 法令文体）を別途持たせるか、どちらかが要ります。Claude 系は穴をユーザに正直に報告してくれるので、Layer 1 のアップデート優先度がより明確になった、とも言えます。重要な留保 — 7 ケースでは差を出し切れないここで率直に書いておきたい留保があります。今回のベンチマークは「自転車青切符」という限定された制度の 7 ケースだけで、フロンティア 4 機種が単体・ハイブリッド両方で同じ 5/7 に並んでしまっています。これは「機種差がない」のではなく、7 ケースの解像度では機種差を分離しきれていないと読むのが正しいです。具体的に、本来見えるはずなのに今回のテストセットでは観測できていない差は次のあたりです。条文ネットワークの広さの差：青切符制度は道路交通法の第7条・第63条の4・第65条・第71条あたりに集中しており、参照リンクの長さが浅い。委任規定（施行令第26条）が絡む TC-001/004 でも、リンクは 1 段。これがたとえば民商事の特別法（破産法・会社法のように関連法令を多段に辿る制度）になれば、Pro と Flash、Sonnet と Opus で reasoning の到達度に差が出る可能性が高いです。長文条文の読み下しの差：今回の対象条文はどれも比較的短く、項・号で分岐するレベル。判例引用や但書のネスト 3 段以上のような構造を含めると、Sonnet と Opus の差が出やすくなるはずです。数値計算・期間計算の差：今回のテストは「いくらの反則金か」を問うだけで、計算は伴いません。「何月何日までに納付すべきか」「過失割合はいくらか」のような計算問題を増やすと、モデル間の信頼性差が表面化します。時系列的な制度変更の追従度の差：青切符は 2026 年 4 月 1 日施行で、各モデルの学習データ取り込み時期に差があります。改正が頻繁な分野（税法・金商法・労働法）でテストすると、knowledge cutoff 由来の差が大きくなります。多言語・固有名詞混在のケース：今回は純粋な日本語クエリ。日英混在のクエリ（「e-bike」「helmet law」など）に対する条文引きを試すと、機種ごとの埋め込み品質差も見えそうです。統計的にも、7 ケースで「同じ 5/7」は誤差範囲です。仮に真の正答率がモデル間で 70% vs 80% で違っていたとしても、7 問では同点に並んでしまう確率は十分にあります。機種選定の意思決定材料として今回の数字をそのまま使うのは危険で、「機種差が消失したわけではなく、現在のテストセットでは見えていないだけ」と読み替える必要があります。このベンチマークが意味を持っているのは、「ハイブリッド構成下で Layer 1 が穴を開けたときの挙動」のような質的な差を浮かび上がらせた点であって、機種ランキングを確定させたわけではありません。次に踏むべきは以下です。テストケースを 数十〜100 ケース のオーダーに増やし、(a) 条文間リンクの段数、(b) 但書のネスト深度、(c) 数値計算の有無、(d) 制度変更時期、をカテゴリとして散らす同じケースを 3〜5 回ずつ繰り返してモデル毎の分散を測る（temperature=0 でも完全決定的ではないため）カテゴリ別に正答率と「失敗の質」（False Negative / honest gap / 細部捏造 / 全面捏造）を集計し、機種選定はカテゴリ別の使い分けで考える7 ケースで結論が同点になった、というのはベンチマーク設計の限界であり、次の宿題です。シリーズを通じての結論5 編で 5 機種を回し終えました。どのフロンティア LLM を選んでも、単体での法規判定はほぼ同じ精度（5/7、71%）。どのフロンティア LLM でも、決定論的 NLP を前段に挟めば数値捏造は消える。Layer 1 が穴を開けたときの挙動はベンダーで割れる。 Gemini は False Negative、Claude は honest gap acknowledgment。Layer 1 を改善するインセンティブは、ベンダー選択を超えて支配的。 TC-003 を救えるかどうかが全機種の天井を上げる唯一のレバーで、ここは TF-IDF を意味埋め込みに置き換えるのが筋になりそうです。「2008 年度に書いた卒論コードが、2026 年の生成 AI の尻拭いをする」というタイトル一発ネタで始まったこのシリーズでしたが、5 機種を順に回してみたら、「卒論ロジックが何を担保し、LLM 側に何を残すのが安全か」という比較的真面目な問いに着地しました。Anthropic Claude を加えて分かった結論は次のひとことです。Layer 1 が完璧でない以上、Layer 2 は「降参できるモデル」のほうが安全。そしてこれは、シリーズ全体を通じて言えることでもあります。「卒論はまだ現役」だが、卒論にも穴はある。穴が空いたときに静かに降参してくれる LLM を Layer 2 に置くのが、規制用途では実用上のスイートスポットでした。読んでいただきありがとうございました。次の課題は 2 つあります。1 つは テストケースを数十〜100 ケースに拡張して、今回の 7 ケースでは見えなかった機種差を、条文ネットワークの段数・但書のネスト深度・数値計算・制度変更時期といった軸で分離すること。もう 1 つは Layer 1 の TF-IDF を意味埋め込みに差し替えて、TC-003 のような語彙ギャップ取り逃しを 4 機種すべてで救えるかを見ることです。後者は、テストケースを増やしたあとでも本質的な天井になっている可能性が高い箇所です。ソースコードはこちらgithub.com]]></content:encoded>
            <author>Shu Kobuchi</author>
        </item>
        <item>
            <title><![CDATA[LookMLなしですぐ可視化!?LookerのセルフサービスExploreとは]]></title>
            <link>https://sreake.com/blog/what-is-looker-self-service-explore/</link>
            <guid isPermaLink="false">https://sreake.com/blog/what-is-looker-self-service-explore/</guid>
            <pubDate>Tue, 28 Apr 2026 04:23:44 GMT</pubDate>
            <content:encoded><![CDATA[はじめに こんにちは。2026年が始まり、あっという間に4ヶ月経ちましたね。 個人的には時間の感覚が早すぎて驚いています。 そんな「あっという間」の中で、悲しくもすれ違ってしまった情報もありました。 それが、Looker […]The post LookMLなしですぐ可視化!?LookerのセルフサービスExploreとは first appeared on sreake.com | 株式会社スリーシェイク.]]></content:encoded>
            <author>Sreake</author>
        </item>
        <item>
            <title><![CDATA[順序の不確実性と決定性 〜 Cloud Dataflow における可換モノイド]]></title>
            <link>https://sreake.com/blog/commutative-monoid-on-cloud-dataflow/</link>
            <guid isPermaLink="false">https://sreake.com/blog/commutative-monoid-on-cloud-dataflow/</guid>
            <pubDate>Mon, 27 Apr 2026 07:53:48 GMT</pubDate>
            <content:encoded><![CDATA[Sreake 事業部の芳賀雅樹 (@silasolla) です． 早速ですが，Cloud Dataflow のパイプラインは，実行のたびにデータの分割や到着順が変わるにもかかわらず，(一定の条件のもとで) 集約結果は同一 […]The post 順序の不確実性と決定性 〜 Cloud Dataflow における可換モノイド first appeared on sreake.com | 株式会社スリーシェイク.]]></content:encoded>
            <author>Sreake</author>
        </item>
        <item>
            <title><![CDATA[「どのくらい速くなったの？」に答えるまでの試行錯誤 ── API パフォーマンス改善と負荷テスト基盤の構築]]></title>
            <link>https://sreake.com/blog/trial-and-error-api-performance-improvement-and-load-test/</link>
            <guid isPermaLink="false">https://sreake.com/blog/trial-and-error-api-performance-improvement-and-load-test/</guid>
            <pubDate>Mon, 27 Apr 2026 07:52:47 GMT</pubDate>
            <content:encoded><![CDATA[はじめに はじめまして。Sreake 事業部にてインターンとして参加しました七海(ななうみ)です。大学では情報系を専攻しており、普段は Web アプリケーションのバックエンド開発を中心に学んでいます。 Sreake 事業 […]The post 「どのくらい速くなったの？」に答えるまでの試行錯誤 ── API パフォーマンス改善と負荷テスト基盤の構築 first appeared on sreake.com | 株式会社スリーシェイク.]]></content:encoded>
            <author>Sreake</author>
        </item>
        <item>
            <title><![CDATA[おい、要件を言葉にしろ]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/04/27/122018</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/04/27/122018</guid>
            <pubDate>Mon, 27 Apr 2026 03:20:18 GMT</pubDate>
            <content:encoded><![CDATA[はじめに察しの良いチームが、いちばん危ない。書かなくても通じてきた、その通じ方が、AIには渡らない。書かれていない常識は、書かれていないというだけの理由で、消える。要件定義は、長く「面倒だが避けて通れない上流工程」と呼ばれてきました。きちんと書こうとすると時間がかかる。書かなくても、チームが察しで埋めてくれる。だから雑にやっても、なんとかなった。たぶん、それが間違っていたわけではないのだと思います。書かない知恵で、私たちはずっと回してきた。それが、AIで開発するようになって、通じなくなりつつある気がします。AIには「察し」がありません。書かれていないことは、想像で埋められる。その想像は、私たちのチームの常識とはたぶん一致しない。一致するときもあるかもしれませんが、一致しなかったときの代償が大きすぎる。それも、信じられない速度で起きます。人間のチームでは、要件の曖昧さは3週間後に気づくコストでした。コードレビュー、結合テスト、QA、本番、と段階的に発覚する。各段階に「察しで埋める」防波堤があったから、致命傷にはならなかった。AIで開発すると、要件1つにつき機能が3時間で実装され、3時間後にはレビューが始まる。違っていれば3時間が消える。書き直す。また3時間。3週間が3時間に圧縮されるのが速さの恩恵だが、同じ理屈で、同じバグも3時間で量産される呪いでもある。差し引きでプラスにしたいなら、要件の精度を上げるしかないのかもしれません。コードを書く時間ではなく、コードを書かない時間の質で勝負するしかない。私がAI時代になって初めて要件をまともに考え始めたのは、たぶんこの転換点に立ち会っているからです。ここで腰を据えるために、私は問いを2つに分けて考えるようにしています。「正しいものは何か」と「それを正しく作るには何が要るか」は、別の問いだということです。AIは2つ目の問いの効率を桁で上げます。1つ目の問いには、ほぼ何も貢献しません。1つ目の問いに人間が答えていなければ、AIは2つ目を高速で間違える。コードを書かない時間の質、というのは、結局この1つ目の問いに費やす時間の質のことです。このシリーズは3部構成です。第1部は、要件とは何か、どうやって発見し、どうやって言葉にするか。第2部は、言葉になった要件をどう実装の現場で動くものにするか。第3部は、動き始めた要件を、時間の中でどう生かし続けるか。3部に分けたのは、要件が3つの違う死に方をするからです。第1部で扱うのは、書かれずに死ぬ要件。第2部で扱うのは、書かれたまま動かず読まれずに死ぬ要件。第3部で扱うのは、書かれて動いていたのに時間に殺される要件。同じ「要件が機能しない」という結末でも、原因と処方が違う。3つを順に積み上げないと、AI時代の現場で要件は生き残れない、というのが私の現在地です。AI時代の現場で、私が掴みかけている輪郭を3部に分けて書いていきます。掴みきれていないところは、掴みきれていないと書くつもりです。このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。AIは要件の欠落を可視化する装置になった要件の曖昧さが事故を起こすのは、AI時代に始まった話ではありません。ずっと前から、要件不足は罪でした。それでもチームが動いていたのは、人間が察しで埋めていたからです。人間のチームには、暗黙知という保険がありました。仕様書に書いていなくても「うちのプロダクトでは在庫切れは検索結果に出さない」のような共通理解。長く一緒にいれば全員が知っていて、新人にも自然に伝わる空気。これが要件文書の欠落を長年補ってきた力です。チームは、語れる以上のことを知っている。AIには、この「語られていない知」が一切渡りません。AIに渡せるのは、明示的に書かれた言葉だけです。コード、ドキュメント、コメント、CLAUDE.md、プロンプトに書かれた指示。それ以外は存在しないのと同じだ。古参エンジニアが体に染み込ませた「常識」も、毎週の朝会で更新される暗黙の合意も、AIには届きません。そしてAIには、人間と決定的に違う性質があります。埋め方が予測できないことです。人間の新人なら、要件が曖昧だと「これってこういうことですか」と聞いてくれる。聞いてくれない場合でも、社内の他のコードやドキュメントを参考にする。同僚に「これ前どうしたっけ」と確認する。空白を埋めるとき、チームの平均値に寄せようとする力が働きます。AIは寄せません。学習データの中で最も頻度が高いパターンに寄せる。それは「世界の平均値」であって、あなたのチームの平均値ではない。だから世間では普通だが自社では絶対にやらない実装、というのが日常的に出てきます。具体的に考えてみます。例えば、こんな要件文があったとする。ユーザーが商品を検索できる。これを人間のチームに渡すと、おそらく当たり前のように在庫切れを除外した実装が出てきます。誰も「在庫がある商品だけ」とは書いていないのに、です。なぜか。チームの常識が「うちは在庫切れを売らない」だからです。書かれていない2文字を、全員が暗黙で補っている。同じ要件文をAIに渡すと、無在庫の商品を含む検索結果が返ってきます。AIにとって「商品を検索する」は、世界の平均値で言えば「在庫の有無を問わず該当する商品を返す」ことだからです。たった「ある」の2文字が抜けているだけで、出力が分岐する。ここまで見て、AIが「悪い」と感じるかもしれません。私はそう思っていません。AIは正直だ。書かれた要件しか実行しない。書かれていない要件は推測で埋める。これは機械として当たり前の振る舞いです。問題は、人間がこれまで「書かなくても伝わる」と思って書かなかった部分が、AI時代になって全て露出したということです。つまり、AIは要件不足を作っているのではありません。昔からあった要件不足を、可視化しているだけです。そして、可視化のスピードが速い。3週間が3時間に圧縮される。同じバグも3時間で量産される。これが速さの罠で、要件の曖昧さが致命的になる構造的な理由です。要件をまともに書く、というのは、AI時代にはやらなければ事故る必須の規律です。気合いの問題ではなく、構造の問題として、書かないと走れない。ここで、私が現場で繰り返し使っている捉え方を1つ書いておきます。AIは、入社初日のとても優秀なジュニア開発者のようなものだ、という見方です。入社初日のジュニアは、コードを書く能力は高い。でも、プロジェクトの背景、業務の暗黙ルール、ステークホルダーの恐れ、過去の失敗から学んだ防御的な書き方、これらを何も知りません。だから、放っておくと、構文的には正しいが業務的には間違ったコードを返してきます。ベテランがその新人を成功させる方法は、はっきりしています。毎日のように仕事の文脈を共有することです。「うちはこういう設計を選ぶ」「過去にこういう事故があった」「このユーザーはこう動く傾向がある」。これを口頭で、コードで、レビューで、繰り返し伝える。文脈が積み上がれば、新人は徐々に、ベテランと同じ判断ができるようになる。AIに対しても、同じです。AIは「あなたが与えた情報以外、このプロジェクトについて何も知らない」と考えるくらいでちょうどいい。要件文、CLAUDE.md、skill、テスト、コードの命名、PRのテンプレート。これらすべてが、AIに対する文脈共有の手段です。書いていない文脈は、AIには届きません。新人を育てる労力を、AIに対しても惜しまない。これが、AIから受け取れる成果の上限を決めます。労力を惜しめば、AIの上限も低くなる。要件を言葉にする規律は、ジュニアを育てる規律と地続きです。「集める」から「引き出す」へ、要件とは何か要件について考え直したとき、最初に捨てたのは「要件を集める」という言い方でした。「要件を集める」と言うと、要件はどこかに転がっていることになる。ステークホルダーの頭の中に整然と並んでいて、ヒアリングという容器を持っていけば移し替えられる、というイメージです。これは嘘だ。要件は転がっていません。ステークホルダーは要件を持っていない。彼らが持っているのは、問題と願望と恐れだけだ。依頼者が「商品を検索できるようにしてほしい」と言うとき、その人の頭の中にあるのは3つの感情です。「在庫切れの商品を売って、また顧客から怒られたくない」という恐れ。「もっと売上を上げたい」という願望。「検索が遅くて離脱されている気がする」という観察。これは要件ではない。要件の原料です。原料から要件を作るのは、依頼者ではない。エンジニア側、要件を引き出す側の仕事です。そして、引き出された要件は最初から正解ではありません。要件は仮説として書かれ、検証によって精度が上がるもの、と捉えるようになりました。完璧な要件を1度で書こうとすると詰まる。粗い仮説で出して、動くもので試して、ズレが出たら直す。正しいものは、最初から手元にあるのではなく、繰り返しの中で形になっていく。要件発見が「採取」でなく「探索」だと考える理由は、ここにあります。ここで「引き出す」をもう一段噛み砕いて考えてみます。要件発見の比喩として「漁る」という動詞があります。網を引いて、水中の見えない対象をしつこく探る。漁船が網を投じるように、ワークショップ、インタビュー、プロトタイプ、シナリオ、ビジネスイベント分析、観察、と様々な「漁具」を使う。ステークホルダー自身が意識していない潜在的なニーズを引き上げる、という営みです。この比喩が刺さるのは、ステークホルダーへのヒアリングを過信しないからです。「ヒアリング = 採取」だと思っていると、聞いて出てきた言葉が要件だと錯覚する。実際には、聞いて出てきた言葉は氷山の一角で、本当に必要なものは水中に沈んでいます。「漁る」という動詞は、その水中に手を突っ込む覚悟を含んでいる。要件発見でもう1つ役に立つのが、症状と原因がズレるという見方です。ステークホルダーが「これが問題です」と挙げてくる場所は、問題が表に出ている場所であって、問題が発生している場所ではない、ということがしばしば起きます。「ヘルプデスクへの問い合わせが多い」という症状の原因が、実は「画面のラベルが分かりにくい」だったり、「マニュアルが古い」だったり、「最初のオンボーディングが不十分」だったりします。症状をそのまま要件にすると、原因に届かない実装が出てくる。しかも厄介なことに、不便は毎日触っているうちに姿を消します。手が覚えてしまえば、操作は意識から落ちる。落ちたものは「困っていない」として記憶される。だから現場でヒアリングすると、最も摩耗の激しい場所が、最も語られない場所になりがちです。症状の不在は、原因の不在ではない。語られない場所こそ、観察者が手で触り直す場所だと、私は決めています。私は要件のヒアリングをするとき、出てきた「これが問題」を、まず症状として仮置きします。症状の場所では止めず、隣の領域、上流の領域、一見無関係に見える領域まで含めて、もう一度なぜを問い直す。3つも遡れば、最初の場所と全然違うところに原因が出てくる、ということが現場で繰り返し起きます。要件発見の経験則として、「なぜ」を繰り返し問い直すことが重要だと言われるのは、この構造から来ています。1回目のなぜで出てくるのは、たいてい次の症状です。本当の原因まで届くには、もう少し奥に進む必要がある、というのが現場の感覚です。「集める」という言い方を捨て、「引き出す」あるいは「漁る」を採用することは、単なる言葉遣いの変更ではありません。要件発見の難度に対する敬意です。難しいからこそ、技術が要る。要件には階層がある要件発見をやっていてすぐに気づくのは、全部を1つの粒度で書こうとすると破綻することです。経営層と話している内容と、エンドユーザーと話している内容と、エンジニアと話している内容は、明らかに違う層にある。これを混ぜて書くと、誰にとっても読みにくい文書ができ上がります。私は3層で整理しています。 層            内容                        例                                    言葉の主      ビジネス要件  組織が達成したい便益        在庫切れによる顧客離れを減らす        経営の言葉    ユーザー要件  ユーザーが達成したいタスク  在庫がある商品だけを検索できる        利用者の言葉  機能要件      システムが示す振る舞い      検索結果に在庫>0のフィルタを適用する  実装の言葉   この3層に加えて、もう1つ独立した分類があります。非機能要件、または品質属性です。性能、可用性、セキュリティ、保守性、ユーザビリティ。「何をするか」ではなく「どれだけうまくやるか」を規定するものです。3層 × 機能/非機能 のマトリクスで、要件文を書くべき場所が決まります。混ざっていると、誰も全体を理解できない。相手と層を合わせて話すこと、機能と非機能を分けて書くこと。これが要件を扱う基本動作です。良い要件と悪い要件を分ける6つの特性要件の良し悪しを判定する基準として、私が拠り所にしている特性が6つあります。完全性には、やることが全部書かれているだけでなく、意図的にやらないと決めたことも書かれていることを含めています。やらない領域を書き残さないと、AIや次の担当者が「未着手の隙間」と解釈して埋めにきます。完全性：開発者が実装に必要な情報を全部含んでいる正確性：ステークホルダーのニーズを正しく反映し、上位要件と矛盾しない実現可能性：技術的・予算的に達成可能である必要性：ビジネス価値があり、規制や標準に対応している優先順位付け：他の要件との重要度の差が明確である検証可能性：テストやレビューで実装の適合性を判断できるこのうちで一番重要なのは、最後の検証可能性だと考えています。私が一番滑るのもここです。書いている最中は完璧に検証可能だと思っているのに、後から読み直すと「ユーザーが満足する」のような主観が混じっている。気づかないと、ときどき滑り抜けます。だから、6つの順番ではなく、検証可能性だけを二度読み返すというルールを自分に課しています。検証可能でない要件は、実装が完了したかどうかを判定できません。判定できない要件は、合意しようがない。合意できない要件は、後で必ず揉める。検証できなければ要件ではない、というのが要件工学の中でも特に強い主張で、私はこれに完全に同意しています。さらに言うなら、判定できる要件は機械に判定させるのが筋です。型で表せるなら型に、テストで書けるならテストに、CIで回せるならCIに落とす。人間が目視で確認するのは最終手段です。「使いやすい検索」は要件ではありません。それは願望です。「在庫がある商品だけを、3クリック以内、1秒以内で表示できる」は要件です。後者は、できた／できなかったが判定できる。私は要件を書くとき、必ず自分に問います。「これ、後で『できた』『できなかった』が判定できる文になっているか」。判定できないなら、それはまだ要件になっていません。願望を実装に渡してはいけない。ただし、検証可能性が一発で書ききれない領域があるのは正直に認めます。探索段階のUX、感情的価値、新規ドメインの初期仮説。こうした領域では、最初から数値で検証条件を書くのは無理があります。私のやり方は、検証可能性の代わりに観察可能性を置くことです。「ユーザーが満足する」は観察できないが、「離脱率」「再訪率」「特定操作の所要時間」は観察できる。観察可能な代理指標を置いて、実際のデータが集まってから検証可能な要件に書き直す。最初から検証可能になれない領域では、観察→検証の順で2段階に分けて書く。これも要件工学の伝統に含まれている発想です。そしてもう1つ、要件文へ併記しているのが、根拠です。「なぜこの要件が必要か」を1〜2行で添える。「在庫切れ商品を含めない」という要件であれば「在庫切れの商品を購入しようとしたユーザーが落胆して離脱する事例が多発した」と根拠を書く。根拠を残しておくと、半年後・1年後にこの要件を見直すときに、判断材料が残ります。在庫切れでも「再入荷予定あり」と表示できるようになったとき、「この要件は再考できる」と即座に分かる。根拠がなければ、なぜそう決めたかが思い出せず、「現状維持」しか選べなくなる。要件は描写と適合基準と根拠の3点セットで初めて、時間の中で生き続けられます。悪い要件は「悪い言葉」で書かれる検証不能な要件は、決まったパターンの言葉で書かれます。これらの言葉が現れたらその要件は壊れていると判断していい、というリストがあります。 悪い言葉                                何が悪いか                どう直すか                              「ユーザーフレンドリー」「使いやすい」  主観的、検証不能          具体的なユーザビリティ基準を数値化する  「高速」「迅速」「軽快」                速さの基準が無い          許容できる最長時間を明記する            「最善を尽くす」「できるだけ」          ゴールが無い              達成すべき水準を定義する                「〜などを含む」                        リストが閉じていない      完全な一覧、または「以下に限定」と明記  「直感的」「見やすい」                  何が直感かは人による      操作回数、視認時間、エラー率で測る      「適切な」「妥当な」                    何が適切かが不明          適切性の基準を明示する                  「効率的」                              効率の指標が無い          何を最大化/最小化するかを書く           「柔軟」「拡張可能」                    何にどう拡張するかが不明  想定する変更パターンを列挙する         これらの言葉は、書く側にとっては書きやすい。読む側にとっても「いい感じだな」と思える。だからプロジェクトの初期によく出てきます。落とし穴は、書き手と読み手で違う絵を見ているのに、両方が「合意できた」と錯覚することです。「使いやすい検索」を見て、書き手は「3クリックで結果が見える」を想像し、読み手は「Googleのような瞬時の検索」を想像している。両者は別物です。実装した瞬間に「思ってたのと違う」となる。これらの言葉が要件に出てきたら、そこが対話の起点です。「『高速』というのは、何秒以内のことですか」と聞き返す。聞き返しの結果として「3秒以内」という数字が出てくる。これでようやく、要件になります。私が一番何度も書いてしまった悪い言葉は「直感的」です。デザイナーから上がってきた要件文を、そのまま要件として渡してしまう癖があった。「直感的なUI」を実装してくれ、とAIに頼んで、出てきた画面を見て「これは違う」と差し戻す。差し戻された側のAIには判断材料がない。3回目の差し戻しのとき、AIに「直感的とは何ですか」と聞き返された。返事に詰まって、画面の前で5秒固まった。デザイナーの意図を、私はこの5秒のあいだ、自分の言葉に翻訳できていなかった。AIが正直なのではない。私が、人間相手だから曖昧で許されていただけだ。8語のリストは頭にある。あるが、自分が書く側に回ると、ない。段階で書いて、願望と現実を分ける非機能要件で特に難しいのが、「どこまでやれば十分か」が一意に決まらないことです。応答時間が0.1秒のほうが速い、それは分かる。でも、0.1秒でなければならないか1秒で十分かは、ビジネスの判断です。これに対する道具立てとして、要件を段階で書くという発想があります。同じ要件を、複数のレベルで併記する。最低限（Must）：これが達成できなければ、機能として成立しない目標（Goal）：通常はここまで達成したい意欲的目標（Stretch）：余裕があれば、ここまで届かせたい願い（Wish）：理想を言えばここまで行きたい応答時間で言えば「最低限3秒、目標1秒、意欲的0.5秒、願いは0.1秒」と書く。こうすると、実装側も「どこまで頑張る必要があるか」が分かる。トレードオフの会話ができる。「目標を達成するのにこの工数がかかる、最低限なら半分で済む」のような議論になる。段階を書いたら、それぞれの閾値は監視ツールに渡して機械が常時測るようにします。人間が四半期ごとに見直す運用は、たいてい止まる。閾値を機械に握らせると、超えた瞬間に通知が来る。判定を人手から外せるかどうかが、段階記述が生きるかの分かれ目だと思っています。段階で書く効用は、もう1つあります。完璧主義を避けられる。すべての要件を「願い」のレベルで書こうとすると、達成不可能な仕様書ができ上がる。「最低限」と「目標」を分けておけば、現実的に出荷可能なラインが見えてくる。ここには、意思決定の本質的な戦略があります。私たちは多くの場面で、最適解ではなく十分な水準を選んで動いてきました。最適化を諦めて満足化に切り替える。これは不確実性の中で動く現場の合理的な判断です。すべての要件で最適を狙えば、どれも完成しない。最低限と目標と願いを分けて書くことで、満たすべき水準と追求してもいい水準を、明示的に区別できる。要件工学の経験則として「完璧な要件は得られない」「目標は十分に良い要件を、許容できるリスク水準で構築すること」という言葉があります。これも、段階で書くことと地続きの考え方です。要件をどう書き出すかの規律ここまでで、要件とは何か、何が良くて何が悪いか、を整理してきました。次は、要件をどう書き出すかの規律に踏み込みます。要件発見の理屈は綺麗に整理できても、現場ではそう綺麗に進まない。見積もりは外れる、要件は曖昧なまま走る、それでも何とか回ってきた——という現実があります。AIで開発する時代に、その現実が新しい形で問われ始めました。要件を発見した後、何を優先し、どんな粒度で、どんな言葉で書き出すか。その作業の規律を、まとめておきます。早く見つけるほど安い、見積もりは確率分布である要件の品質を真剣に考えるべき経済的な理由として、欠陥が遅く見つかるほど修正コストは桁で跳ね上がるという構造があります。要件の段階で見つかれば、要件文を直すだけ。設計の段階で見つかれば、設計と要件を直す。実装の段階で見つかれば、コードと設計と要件を直す。運用の段階で見つかれば、それに加えて、本番のデータの整合性、顧客への謝罪、ロールバックの段取り、再発防止策の整備、まで全部が乗ってきます。同じ欠陥でも、見つかった瞬間が後ろに行くほど、巻き込まれる範囲が増える。実装の労力がAIで小さくなっても、運用後に発覚した欠陥の被害範囲は変わりません。むしろ実装が速くなったぶん、本番までの距離が短くなる。要件で見つけることの相対的な価値は、AI時代のほうがむしろ上がっている。ここに、見積もりの世界からもう1つの示唆を持ち込みたいです。「要件が固まらないと見積もりも固まらない」という関係です。プロジェクト初期の見積もり誤差は、信じられないほど大きい。「短く済むかもしれないし、何倍にも伸びるかもしれない」という幅で出てくることが普通です。要件が固まり、設計が詰まり、実装が進むにつれて、この幅は段階的に縮まっていきます。要件を固める作業は、見積もりの幅を縮める作業でもある、という関係です。逆に、要件が曖昧なまま走ると、見積もりの幅は狭まりません。プロジェクトが進めば自動的に確実になる、というのは幻想です。要件を明確に決め、設計を詰め、意思決定を重ねることでのみ、不確実性は減る。AI時代になって実装が速くなっても、この構造は変わらない。要件を曖昧にしたまま速く実装するのは、広い不確実性のまま速く走ることであり、たいてい広い範囲のどこかで事故が待っています。私の場合は、ちょうど真ん中で待っていました。要件が固まらないうちに「3か月でできます」と返したことがある。実装はAIで速かった。3か月後、動いてはいたが、業務が回っていなかった。速く作ることと、正しいものに着地することは、別の問題だと気づくのに、もう3か月かかった。そして、もう1つ重要な区別があります。見積もりとコミットメントとターゲットは別物だ、ということです。見積もりは予測です。「これだけのスコープなら、たぶんこのくらいの工数で終わる」。客観的な分析の結果。ターゲットは目標です。「展示会までに出したい」「四半期末までに終わらせたい」。ビジネスの希望。コミットメントは約束です。「この日までに、これを出すと約束する」。組織への宣言。この3つを混ぜて議論すると、対話が必ず壊れます。経営が「3か月でやってほしい」（ターゲット）と言ったとき、エンジニアが「7か月かかります」（見積もり）と返すと、両者の言葉は噛み合いません。両方とも正しい。ただ、別のことを言っているだけです。ターゲットと見積もりがズレているとき、それはプロジェクトが失敗するかもしれない貴重な信号です。選べる道は4つしかありません。スコープを削る、予算を増やす、目標を調整する、やめる。ズレを早期に検出するのが、要件発見の役割でもあります。要件を曖昧にしたままプロジェクトを走らせると、このズレが見えないまま終盤まで進み、最後の3つの選択肢（予算増・目標調整・中止）すら手遅れになります。要件の検討に時間をかけるのは、開発を遅らせる行為ではありません。全体としては開発を加速させる投資であり、同時に、組織の意思決定を健全に保つための情報収集でもある。適当だった見積もりと要件、それでも動いてきた現場ここで一度、自分たちが立ってきた現場を、敬意を持って振り返っておきたいです。事実として、要件や見積もりは、現場で長く適当に扱われてきました。「適当」は、いい加減という意味ではない。正確には決められないものを、正確に決めずに動かしてきた、という意味です。現場の怠慢ではなく、不確実性を扱うための知恵の選択でした。見積もり誤差はプロジェクト初期に大きく、要件が固まるにつれて段階的に狭まる。初期段階で正確な数字を出すのは物理的に不可能ですが、それでも数字は要求される。エンジニアの自己申告は楽観に出やすい。「ほぼ確実」と思っていた見積もりが半分も当たらないことは珍しくない。個人の怠慢ではなく、視界の構造と認知の自然な特性です。それでも回ってきたのは、先人たちが多層のバッファで楽観の数字を吸収する仕組みを暗黙に組み上げてきたからです。ベテランが若手の見積もりを肌感で割り引く。プロジェクトマネージャが長めの納期で発注する。最終的に機能を一部削って出す。これは欠陥ではなく、不確実性を組織と個人で吸収する高度な調整でした。要件のほうも同じ構造でした。「ユーザーが商品を検索できる」のような検証不能に見える要件文が普通に承認されてきた。それでも回ったのは、現場のエンジニアと依頼者の間で、書類の外側にある共通の業務理解が動いていたからです。ドキュメントの不完全さは、組織の暗黙知が補っていた。これは怠慢の選択ではなく、知恵の選択です。ところが、AI時代になって、この補正の経路が急速に脆くなることに気づき始めました。AIは要件文の通りに実装します。書かれていない常識は補完しない。ベテランが暗黙で補正していた差分は、コードに直接漏れ出します。「3か月で」と指示されれば3か月分のスコープに合わせるが、本当に3か月で終わるかの判断はしない。人間の楽観的な数字が、補正されないまま実行に流れる。これは先人たちの知恵が無効になった話ではありません。前提が変わっただけです。ドキュメントの外側で動いていた補正を、ドキュメントの中（コード、型、テスト、CLAUDE.md、skill）に移していく必要が出てきた。私の現在の方針は、先人たちが暗黙でやっていたことを、明示的に書き直すことです。単一の数字ではなく分布で書く。検証可能な要件文に書き直す。漏れていた活動を見積もりに含める。AIへ渡す前、自分の頭の中で行ってきた補正を1つ書き出しておく。これは暗黙の補正系を明示の補正系へ翻訳する作業であって、ゼロから始めるのではない。既にある知恵を、新しい器に注ぎ直しているだけです。機能の優先順位を多面的に見る要件を引き出して書き出すと、すぐに次の問題が来ます。全部を一度に作れないのだから、何から作るかを決めなければなりません。ここで言う優先順位は「どの機能から作るか」の話です。非機能要件の優先順位は性質が違うので、別の規律として第2部で扱います。優先順位付けは、現場でしばしば「重要度」の一軸でやられます。最重要、重要、普通、低い。これは判断としては乱暴で、揃って「最重要」の要件が10個並ぶ、という状況に陥りがちです。私が現場で使っているのは、複数の軸で見直す方法です。少なくとも次の5つの軸で、それぞれ要件を点数化します。期待される収益・効果：その要件が実装されたとき、ビジネスにどれだけの便益をもたらすか。売上、コスト削減、満足度向上、いずれかで測れる。ビジネスリスク：実装しなかった場合の損失。法令対応、セキュリティ、信頼失墜、競合への遅れ。実装しないこと自体がリスクになる要件は、優先度が高い。実装リスク：実装しようとしたときの不確実性。技術的に難しい、未経験のドメイン、依存関係が多い、これは実装リスクが高い。早期に試して情報を得たい要件は、リスクが高くても優先度を上げる場合がある。データと依存の前提条件：他の要件が先に動いていないと、この要件は意味をなさない、という関係。前提となる要件が完成していないなら、後段の要件をどれだけ高優先度にしても、待たされる。ステークホルダーの可視性：実装したことが、誰の目にどう映るか。営業の前で見せる場面、顧客の最初の体験になる場面、社内のキックオフで紹介される場面。こういう「見られる場面」がある要件は、心理的な意味で優先度が高い。5軸で点数化して合算すると、単一軸では見えなかった順序が出てきます。期待される収益は中くらいだが、依存関係の起点になっているからまず作る、というような判断ができる。順序は単一の数字ではなく、複数の軸の合議で決まる、というのが私の現在地です。そして、優先順位はプロジェクトの初期に固定しないことが大事です。しばらく走った後に状況が変わる。新しい情報が入る。市場が動く。優先順位の根拠そのものが、時間とともに見直しの対象になります。要件の発見が反復的なら、優先順位の判断も反復的です。今日の最優先が、しばらく経っても最優先である保証はない。点数をつけるのは誰か5軸で点数化する、と書きました。それで決まると書きました。書いてから、自分で立ち止まります。点数をつけるのは誰か。私が一度やった失敗を書いておきます。期待される収益、ビジネスリスク、実装リスク、依存の前提、可視性。5軸を表にして、要件30本に点数を振りました。表は綺麗にできあがった。順序も明確に出た。会議で表を見せました。営業の責任者が、自分の案件の点数を見て、黙りました。次の週、別の営業の責任者から、点数の根拠を問う長文のメールが届きました。表は嘘をつかない。表を作った私が、嘘をついていた。期待される収益の点数を、私は自分の感覚で振った。実装リスクの点数も、私が振った。依存関係の点数も、私が振った。営業の現場感、経営の戦略意図、現場の温度。これらは表のセルの中で1つの数字に圧縮されていた。圧縮した私の解釈が、組織の判断として走りそうになっていた。5軸そのものは正しい。たぶん。問題は、軸の重みと、各セルの点数を、誰がどんな根拠で決めるかでした。点数をつける人が変われば順序が変わる。重みを1.5倍にする軸を変えれば順序が変わる。点数は客観的に見えますが、点数の生成プロセスは政治と認知のバイアスでできている。表の見た目に騙されると、合意していないものを合意したことにできる。今は、点数を1人で振らないようにしています。少なくとも収益は営業と、実装リスクはエンジニアと、依存は設計者と、可視性は経営と、それぞれの一次情報を持つ人と一緒に振る。点数の根拠を1〜2行のメモで残す。重みを変えると順序がどう動くかを、表で並べて見せる。順序を1つに固定して持っていかない。点数化は意思決定を置き換えない。意思決定の対話の入り口に過ぎない、というのが今の私の現在地です。5軸は道具です。道具は使い手の手癖を映します。道具を綺麗に使えば判断が綺麗になる、というのは幻想で、道具を使う前と使った後に、人間の側で考え続ける時間が要る。点数を見て楽になりかけたら、自分が何を圧縮したかを思い出すようにしています。要件はビジネスイベント単位で分けて書く優先順位を決めたとして、その要件をどの粒度で分けて書くか、という問題が次に来ます。私が現場で使っているのは、ビジネスイベント単位で要件を分割する考え方です。ビジネスイベントとは、業務の流れの中で「外から何かが起きる」瞬間です。「顧客が商品を注文した」「在庫が補充された」「支払いが完了した」「キャンセル要求が届いた」。それぞれが、システムが応答すべき独立したトリガーです。機能ごと、画面ごと、ユーザーロールごとに要件を分ける流派もありますが、ビジネスイベントで分けると、いくつかの利点があります。1つ目は、要件の独立性が保たれることです。1つのビジネスイベントに対する応答は、システムの内部設計から切り離して記述できます。「注文が入ったとき、在庫を確保し、決済を予約し、配送伝票を発行する」のような、業務として完結した単位で書ける。これは内部実装が変わっても陳腐化しません。2つ目は、ステークホルダーの専門家が特定しやすいことです。「商品検索機能」だと、誰がオーナーか曖昧になります。「在庫補充イベント」なら、倉庫担当の責任者が明確に決まる。要件発見の対話相手を選びやすくなります。3つ目は、増分開発と相性がいいことです。1つのビジネスイベントの応答を、独立して実装してリリースできる。複数のイベントが互いに干渉しないように設計しておけば、ビジネスイベントごとに優先順位を付けて、価値の高い順から実装していけます。要件をビジネスイベント単位で書く規律は、システムの内部設計と要件を切り離すための装置でもあります。実装の言葉で要件を書くと、内部設計が変わるたびに要件が陳腐化します。ビジネスイベントの言葉で書くと、業務が変わらない限り要件は生き続けます。AI時代に実装はAIに任せ、合格基準だけは人間が握るという構えを取るとき、この粒度設計が効いてきます。「在庫の2文字」を要件文に書く具体例で締めくくります。AIに渡す要件文として、悪い書き方と良い書き方を並べてみます。悪い要件文。商品検索APIを実装する。GET /api/products?q={query} を提供し、products テーブルから LIKE 検索でマッチする商品を返す。レスポンスは JSON 形式で、id, name, price, stock を含める。ユーザーフレンドリーに、なるべく高速に。良い要件文。ユーザーは、購入可能な商品を名前の一部で検索できる。- 在庫がない商品は結果に含まれない- 検索文字が商品名の一部に含まれていれば該当する- 結果は1ページに最大20件、関連度順- 該当0件の場合は「見つかりませんでした」と表示する- 検索開始から結果表示まで、最低限3秒、目標1秒、意欲的0.5秒、願い0.1秒悪い例は実装を書いています。LIKE検索やJSONレスポンスは、エンジニアの判断であって、依頼者が決めることではない。さらに「ユーザーフレンドリー」「なるべく高速」という、悪い言葉が入っている。これでは、できたかどうか判定できない。良い例は、依頼者と合意できる粒度で、検証可能な条件だけを書いています。在庫がない商品を含めない。20件。関連度順。0件時の文言。応答時間の段階。すべて「できた／できなかった」を判定できる。実装はAIが選んでよい。LIKE検索でも、転置インデックスでも、ベクトル検索でもいい。実装はAIに任せ、合格基準だけは人間が握る。ただし、決済・個人情報・人命や安全に関わる領域では、合格基準だけ握れば十分、とは言えません。実装の選択そのものが脅威モデルや規制適合に直結するため、実装手段にも人間のレビューを残す判断が要ります。検証可能性の議論は、ここでは「合格基準を握る」だけでなく「許容できる実装手段を限定する」まで踏み込むのが安全側の倒し方です。そして大事なのは、悪い例には「在庫の2文字」が無いことです。良い例の「在庫がない商品は結果に含まれない」は、過剰に詳しい指定に見えるかもしれない。でもこの1行が無いだけで、AIは無在庫商品を含む実装を返してきます。書かれていない常識は、AIには届かない。要件の独立性は、AI時代の資産価値そのものだと考えています。問題領域の言葉で、検証可能な形で適切な層に置かれ、悪い言葉を避けて書かれた要件は、AIに渡せば何度でも実装し直せる。要件が腐らない限り、コードは流動的でいい。これが、AI時代の要件の形です。「やらないこと」と「想定していないこと」を明記する「在庫の2文字」を書くこと、つまりやることを明示するのが要件の半分だとすれば、もう半分はやらないことと想定していないことを明示することだと考えるようになりました。AI時代になって増えた書き仕事は、この後ろの半分です。人間相手の要件文では、書かれていない部分は「常識」「現状維持」「特に指示なし」と解釈されました。先輩や同僚は、書かれていない領域を察しで埋めてくれた。AI相手の要件文では、書かれていない部分は世界の平均値で埋められます。世界の平均値の中には、「あえてやらない」「この場合は対応しない」「この属性は分岐させない」のような負の指定は含まれません。肯定形だけで要件を書くと、AIは黙って「世界の平均が要求してくる挙動」を実装してきます。実例を1つ。「ユーザーが商品をカートに入れる」という要件を書いて、「ログインしていないユーザーの扱い」を書かなかったことがあります。AIは未ログインでもカートに入れられる実装を返してきた。私たちのプロダクトでは、未ログインのカート操作は明示的にやらない設計だった。書かなかった。だから消えた。「ログインしていないユーザーは、カートに入れられない」と1行書いていれば、3時間が消えずに済んだ。在庫の2文字と同じ構造の事故が、今度は「やらない」の側で起きていました。もう一段先があります。「想定していないこと」も想定していないと明記する必要が出てきた、ということです。「年齢層は問わない、地域による分岐は想定していない、1リクエストあたり100万件を超えるデータは現バージョンの対象外」。書かなければ、AIはありそうな分岐を勝手に想定して実装します。良かれと思って入れた条件分岐が、要件にない複雑さを実装に持ち込む。想定していないことを想定していないと書く——これは、人間相手なら過剰だった書き方です。AI相手だと、ちょうどいい温度になります。正直に言うと、私もまだ慣れていません。要件文に「やる」を書くのは自然ですが、「やらない」を書くのは不自然な感覚が抜けない。書きながら「これ、書かなくても分かるだろ」と思ってしまう。相手は分からない。AIは「分かるだろ」を学習していない。頭で知っていても、手が止まる。書く側の常識が、書かれない領域を作る。AIはその領域に世界の平均を流し込む。書く側が自分の常識を疑わない限り、書き漏らしは止まりません。「やる」「やらない」「想定していない」の3つを揃えて、初めて1つの要件は閉じます。閉じていない要件は、AIにとって世界の平均で埋めていい余白です。要件文を読み返すとき、私は最近、「ここで書かれていないことを、AIは何で埋めるか」を一度ずつ自問するようにしています。3つのうちどれかが抜けていたら、抜けたところに平均が流れ込む。流れ込んだ平均は、たいてい、こちらの常識と一致しません。問題空間を見る前に、解空間に飛ばない要件を引き出すという話の続きですが、もう一段だけ手前の話をしておきたいです。観察の話です。良い要件文を書く前に、もっと前段でやるべきことがあります。何を解こうとしているのかを見極めることです。要件工学の中でも特に強い区別が、問題空間と解空間を分けるという考え方です。問題空間は「ユーザーが達成したいジョブ、満たしたいニーズ、抱えている制約」の世界。解空間は「それに対して何をどう作るか」の世界。両者は地続きに見えますが、混ぜると要件が壊れます。混ぜるとどうなるか。「商品検索画面が欲しい」という言葉が、要件として入ってきたとします。これは解空間の話です。問題空間ではありません。問題空間にあるのは「ユーザーは買いたい商品を素早く見つけたい」「無駄な操作で離脱させたくない」「在庫切れ商品をクリックさせて落胆させたくない」といったニーズです。「検索画面」は、それらのニーズに対する1つの解にすぎない。問題空間に立ち戻らないまま要件を書くと、最初に出てきた解の延長で要件が硬直します。本当に必要だったのは検索画面ではなくレコメンド機能だったかもしれない。あるいは検索画面だったとしても、フィルタ・並び順・在庫フラグの扱いは、ニーズから逆算しないと決まらない。良い要件は、解空間で書かれていても、問題空間に根を持っている必要があります。本質的な難しさと、付随する難しさ問題空間と解空間を分けると、もう1つ便利な区別が見えてきます。本質的な複雑さと付随的な複雑さの区別です。本質的な複雑さは、解こうとしている問題そのものが持つ複雑さです。「在庫管理を、複数倉庫、複数チャネル、リアルタイム、誤差を許さない、で動かす」。これは、業務領域が持つ本来の難しさです。どんな技術を使っても、消えません。付随的な複雑さは、その問題を実装するときに、たまたま発生する複雑さです。フレームワークの癖、ライブラリのバージョン差、デプロイ環境の制約、ボイラープレートのコード。これは、業務とは関係なく、ツールチェーンや実装手段に由来します。AIが得意なのは、付随的な複雑さの処理です。フレームワークのお作法に沿ってコードを書く、似たパターンの繰り返しを生成する、ライブラリの呼び出しを正しく組み立てる。これは、AIで桁違いに速くなりました。逆に、AIが本当の意味で代替できないのは、本質的な複雑さの理解です。業務領域を深く知る、ステークホルダーの矛盾を解きほぐす、過去の失敗から学ぶ、未来の変化を予測する。これは、ドメインの中で時間をかけて積み上げてきた知の蓄積で、文章で書き出せる範囲を大きく超えています。要件発見の仕事は、この本質的な複雑さに人間が向き合うことです。付随的な複雑さから解放されたぶん、空いた認知を本質の解明に投入する。これが、要件発見がAI時代に重みを増す理由です。ここを混同すると、危険なことが起きます。AIに本質的な複雑さまで丸投げしようとすると、AIは推測で埋めてしまいます。AIが返してくる「なんとなく整合的に見える要件」が、実は業務とまったく合っていない、ということが起きる。本質的な複雑さは、人間が引き受ける領域だ、という線引きが要件発見の出発点です。人は製品ではなく「片付けたいジョブ」を雇う問題空間を覗き込むときに、私が拠り所にしている見方があります。人は製品を買うのではなく、自分のジョブを片付けるために製品を雇う、という見方です。ジョブとは、その人が達成しようとしているタスク、目標、目的です。回避したい問題や、解消したい不快も含みます。重要なのは、ジョブは製品の外側、ユーザーの生活の文脈の中に存在するということです。プロダクトの機能はジョブを片付ける手段にすぎません。例として、社内の業務システムを考えます。「経費精算システム」を雇った担当者のジョブは、「経費精算をすること」ではありません。本当のジョブは「月末の作業を最短で終えて、本来の業務に戻る」だったり、「経理から差し戻されない正確なドキュメントを出して、自分の評価を傷つけない」だったりします。ジョブは仕事の合間で発生する、割り込みコストとしての処理だ、というのが利用者の見方です。ジョブが見えると、要件が変わります。「経費精算システム」の要件として「画面遷移をシンプルにする」は表層的です。本当に効くのは「領収書の写真を1枚撮ったら、ほとんどの精算が完了している状態」のような、ジョブの完了に直結する要件です。ジョブを把握していないと、機能を足すたびに「使いやすくなった」と言いながら、ジョブの片付けは遅くなる、という現象が起きます。ジョブには種類があります。機能的なジョブは、達成したいタスクの実体です。「経費精算を完了する」「リモートで授業を進行する」。感情的なジョブは、ジョブを片付けるときに感じたい気分です。「不安なくミスなく終えたい」「自分が遅れていないと感じたい」。社会的なジョブは、他者からどう見られたいかです。「真面目に処理する人だと評価されたい」「同僚に迷惑をかけたくない」。3種類は同時に存在します。機能だけを満たしても、感情と社会の側で違和感が残ると、ユーザーはその製品を「雇い続けて」くれません。要件が機能ジョブに偏ると、見落とされたジョブの分だけ、満足度が下がる。ジョブを軸に要件発見をすると、ユーザー属性ベースの議論から、ジョブベースの議論に変わります。「30代女性の検索行動」を分析するのではなく、「忙しい平日の夜に、明日の朝までに必要なものを買い切るジョブ」を分析する。前者は属性に紐づいた解釈で、後者は文脈に紐づいた行動です。AIに渡す要件文を書くときも、属性ではなくジョブから書くほうが、出てくる実装の精度が上がります。ジョブから書くという見方は、AI時代になって相性が良くなったと感じています。AIは平均的な属性データから平均的な解を返してきます。ジョブで切り直すと、属性をまたいで通用する問題の核が見えてきて、AIの出力もそこに焦点を合わせられる。言葉の境界が、そのままドメインの境界になる問題空間を観察するときに、私が一番気をつけているのが言葉の選び方です。ドメインで使われる言葉を、そのまま要件に持ち込む。エンジニア同士の符丁に翻訳しない。「ユーザー」と書きたくなる場面で、ドメインに「購入者」「会員」「ゲスト」の区別があるなら、その区別を保ったまま要件に書く。言葉を統一する規律が、ドメインの輪郭を要件文へ乗せる手段になります。これは古い話のようで、AI時代になって威力が増しました。AIは渡された言葉から世界を再構成します。要件文の言葉とコードの命名、それにデータベースのカラム名がズレていると、AIには「このユーザーとあの会員の関係」が解釈できません。一致して書いてあれば、解釈の揺れがなくなる。言葉の統一が、AIへのコンテキストの密度を上げる。そしてこの言葉は、コンテキストの境界と一致します。同じ「セッション」という単語でも、認証ドメイン・UI・分析ドメインでは別の概念です。一見同じ言葉でも、文脈が違えば意味が違う。混ぜれば、要件とコードが壊れる。だから要件には、どの文脈で使っている言葉なのかを必ず明示します。文脈ごとに異なるモデルを許す、という設計の規律が、要件の精度を底上げします。文脈の境界が引かれていない要件文は、AI時代にあっという間に破綻する。観察の難しさ、言わないこと、言えないこと、見ないと分からないこと「観察」と言うと、ヒアリングを丁寧にやることだと誤解されがちです。違います。観察とは聞かなくても分かる事実を見つけにいく営みです。そして、観察は思っているより難しい。ユーザーは多くのことを言いません。一番頻度が高いのは「不便すぎて、もう不便だと思っていない」というパターンです。毎日同じ操作を長年やっていると、その不便は風景の一部になります。聞いても「特に困ってない」と返ってくる。困っていないのではなく、困っていることに気づかなくなっているだけです。観察者がその不便を発見するためには、別のドメインや別のユーザー体験と比較する視点が要ります。ユーザーは多くのことを言えません。何かが不便だと感じていても、それを言葉にする語彙を持っていない場合があります。「なんかイライラする」「使ってると疲れる」「終わった後にぐったりする」。こうした感情の手前にある、もっと具体的な原因を本人が特定できないまま、漠然とした不満だけが語られます。これを聞いて「イライラする」を要件に書いても、何も解けません。観察者が、その感情の発火点を現場で目撃する必要があります。そして、ユーザーは見ないと分からないことを持っています。画面の前で実際に何を見ているのか。どこで手が止まるのか。何度操作をやり直すのか。本人に聞いても、ほとんどが思い出せません。「自然にやっている」からです。自然にやっている操作の中にこそ、設計上の摩擦が埋まっています。これは現場で観察するしかない。業務システムなら、観察対象は画面操作だけではありません。現場で人が紙に書き出している項目、Excelで作っている裏マスタ、メモ帳に書かれた手順書、付箋に書かれた値、ホワイトボードに残った計算式、口頭で引き継がれている例外処理。これは正式な業務として記録されないことが多いですが、本当の要件はそこに眠っています。正規の文書には載らない運用の機微を、観察で拾う。これが言葉になっていない要件の発見です。観察にはもう1つ、根本的な難しさがあります。観察者バイアスです。観察する側は、すでに自分の解釈の枠を持っています。「ここが不便なはずだ」「こう改善すれば効くはずだ」という仮説を持ったまま現場に行くと、仮説に合う事実だけが目に入ります。仮説に合わない事実、自分が予想していなかった現象は、観察したのに気づかない、ということが起きます。私が現場で気をつけているのは、仮説を持って現場に行くが、現場では仮説を一旦置くことです。先入観のまま観察すると、確証バイアスで歪みます。先入観を完全に消すのは無理ですが、現場で起きている現象を、自分の仮説で解釈する前に、まず記述するように動きます。「ユーザーは経費精算で困っている」と解釈する前に、「画面が出てから入力開始までしばらく目線が上下に動いている」と記述する。記述してから解釈する順序が、観察を健全に保ちます。集合的な観察の手段もあります。複数の関係者を集めて、業務の流れを時系列でマップしていくワークショップは、個人の観察では届かないドメインの輪郭を浮き上がらせます。経理が「ここで差し戻しが頻発している」と書き、現場が「私はここで諦めて手書きに戻している」と書き、システム担当が「このデータがここで重複している」と書く。それぞれの視点が交差する場所に、本当の問題があります。部屋の中に、必要な知識が揃っている——これがワークショップ形式の本当の価値です。別々にインタビューすると断片を繋ぐ作業が膨大になりますが、1つの部屋に全員を集めれば、繋ぐ作業をその場で終わらせられる。観察の最後の難しさは、解空間に飛びたくなる衝動です。困っている現場を見ると、すぐに「これを実装すれば解決する」というアイデアが浮かびます。これはたいてい早すぎる合図で、観察を続けると最初のアイデアでは届かない別の問題が見えてきます。観察は、面倒くさくなるまで続けるくらいでちょうどいい。AIには、この観察ができません。マルチモーダルが進んだとはいえ、現場の身体的な観察を人間と同じ重みで意味づける段階にはまだ至っていない。視線の動きや付箋の落書き、口頭の例外処理は、AIに渡せる形で残らない。観察してテキスト化することは、AI時代に価値が上がる仕事です。観察の生データはノイズが多くAIに直接渡しても整理がつかないので、解釈し、構造化し、要件として書き直す。観察 → 記述 → 解釈 → 要件の変換パイプラインの最初の3ステップは、当面、人間の領域です。プロトタイプは観察の道具になる要件発見の最後に、もう1つ書いておきたい技法があります。プロトタイプを早く作って、観察の道具に使うことです。従来、プロトタイプは「実装の前段、要件が固まった後で作るもの」と位置づけられていました。AIで開発が速くなった今、この順序は逆転できます。要件を厳密に固める前に、AIで荒いプロトタイプを作り、それをユーザーに触らせて反応を観察する。触るプロトタイプは、聞くインタビューより遥かに多くの情報を引き出します。文章の要件文を見せて「これでいいですか」と聞くと、ステークホルダーは自分が想像できる範囲でしか答えません。動くプロトタイプを触らせると、想像できなかった反応、想定外の操作、言葉になっていなかった違和感が浮き上がります。「これは違う」「ここでつまずく」「こういう使い方をしたい」。ステークホルダー自身が気づいていなかった要件が、触ることで初めて言葉になります。AIで実装が速くなったぶん、この観察ループを1日に何度でも回せるようになりました。要件を固めてからプロトタイプを作るのではなく、プロトタイプを作りながら要件を固めていく、という順序です。ただし、プロトタイプはあくまで観察の道具です。プロトタイプそのものを完成品の方向に育てると、不要な機能が積み重なって、本来の要件から外れていきます。観察が終わったらプロトタイプは捨てるくらいの覚悟が要ります。残すのは、観察から得られた要件文のほうです。プロトタイプは要件の出発点ではなく、要件発見の触媒だ、というのが私の現在地です。コアドメインを見つけて、戦略的に投資する観察と言語化を通じて見えてくるのは、ドメインの中に重みの違う領域があるということです。すべての領域に同じ熱量を注ぐ必要はありません。ドメインの中身は、おおまかに3種類に分かれます。コアは、組織を競合と差別化する、独自の価値を生む領域。支援は、業務に必要だが差別化要因ではない領域。汎用は、業界共通の標準的な機能で、外部の既製品で十分な領域。要件の重さは、この分類で大きく変わります。コアでは、独自の概念を共通言語で精緻に定義し、検証可能な要件を厚く書く。支援では、業界の標準パターンに合わせる。汎用では、SaaSや外部APIに任せて、要件文は最小限。コアに労力を集中するのが、要件投資の正しい配分です。そしてここに時間軸の罠があります。今日のコアが、しばらく経った後もコアである保証はない。技術が進化し、コモディティ化が進むと、かつてのコアが汎用に降格します。要件の重みは、ドメインの進化段階に合わせて、定期的に見直す必要がある。書かれた要件をそのまま守っていると、いつの間にか「もう汎用なのに過剰な独自要件を維持している」という状態になります。要件は静的でない。ドメインの進化に追随する動的な資産だと考えています。チームの境界とドメインの境界を一致させるもう1つ、観察すると見えてくるのは、チームの境界が要件の境界に影響することです。1つのドメインを2つのチームで分けて持つと、要件の議論が必ず歪みます。逆に、複数のドメインを1つのチームで持つと、認知負荷で詰まる。この問題は古くから議論されてきました。システムの構造は、組織の構造を反映する。要件をきれいに書こうとしても、組織の境界がきれいでなければ、要件もきれいになりません。実務的には、チームの境界とドメインの境界を意図的に揃えるのが効きます。1つのチームが1つのコンテキストを持つ。コンテキスト間の依存はAPI契約として明示する。チームが要件のオーナーシップを持てるなら、要件の質は自然に上がります。所属が曖昧な要件は、誰にも本気で守られません。私自身、これを軽く見て足を取られたことがあります。1つのコンテキスト——たとえば在庫——を、倉庫管理チームと販売管理チームに二分して持たせていた時期がありました。要件文は何度書き直しても、両チームで「在庫」の意味が微妙にズレる。倉庫の在庫は物理的な棚卸の数で、販売の在庫は引当済みを差し引いた残数だった。同じ単語に違う定義が宿っていた。要件を綺麗に書こうとして3回書き直しましたが、3回とも片方のチームから「これは違う」と返ってきた。書き手の問題ではありません。1つの言葉に2つの所有者がいる構造を、要件文の側から修正することはできなかった。組織の線を引き直して初めて、要件文は1度で通りました。要件発見より前に、組織の線を見ているか。私はこの順序を、いま、自分への問いとして残しています。これも観察の話に戻ってきます。組織を観察し、認知負荷を観察し、チーム間の依存関係を観察する。観察した結果としてチームを再編する判断は、要件定義の前段にあります。要件は組織の影であり、影だけを直そうとしても本体は動かない。観察の結果として、ようやく要件が言葉になる。言葉になった要件だけが、はじめて実装に渡せる対象です。観察 → 言語化 → 実装、というのが私の中の順番です。揺らぎと固執に、あなたの仕事が残る要件を言葉にする話を、もう一段角度を変えて続けます。観察を通じて自分たちのコンテキストの個別性を発見できたとして、それをAIから守るためには何が必要か、という話です。AIで開発する組織を観察していて、ここ数ヶ月、似た現象に何度も気づきました。みんなの出力が、だんだん同じになっていく。同じツール、同じ答え同じモデル、同じハーネス、同じプロンプトを使えば、出てくるコードはほとんど同じです。同じ最適解。同じパターン。同じ命名。同じテストの書き方。同じエラー処理。同じ命名規則。これは効率としては素晴らしい。チーム間で書き方がブレなくなり、レビューが軽くなる。でも、組織のレベルで見ると、危ない。組織Aと組織Bが同じツールを使い、同じ最適解を採用すれば、両者の差は消えます。プロダクトの設計判断、UIの細部、エラー処理の癖、エッジケースの扱い、データの持ち方、APIの命名、ログの書き方。全部、AIの平均値に収束します。少し前なら「このコードを見たらAさんが書いたと分かる」「このAPIの設計は明らかにB社の流儀だ」と感じられたものが、消えていく。癖には、それなりの意味があったはずだ。少なくとも、誰が書いたかが伝わる程度には。これは個人の生産性の問題ではなく、組織の生産性の天井がどこに引かれるかの問題です。AIの最適解は「世界の平均値」AIは過去の大量のデータから「もっとも一般的に正解とされてきたもの」を返してきます。それは「世界の平均値」です。あなたのプロダクトの個性は、世界の平均値の中には無い。これは責めるべきAIの欠陥ではなく、AIの定義そのものです。学習データの中で多数派だった解が「最適」として返ってくる。少数派だった解は、たとえあなたの組織にとって正しい判断であっても、AIの評価では下位に沈む。要件文をAIに渡して実装させると、出てくるコードは「業界標準」に寄ります。要件の余白をAIが埋めるとき、その埋め方は世界の平均で決まる。書いていない部分は、世界に同質化される。これは要件の欠落の話と地続きです。書かれていない部分は、AIが想像で埋める。AIの想像は、世界の平均値に向かう。ここから出口は2つしかありません。書かない部分を残して同質化を受け入れるか、書く範囲を増やして自分たちの個性を保つか。揺らぎとは、AIの評価が低い選択を意図的に取ること差別化を生むのは、AIの評価では低い選択を、意図的に取ることです。これは多くの場面で起きます。AIが「この実装パターンが標準です」と提示してきたとき、自分たちの文脈ではあえて違う選択を取る。ユーザー体験の細部で、業界の常識から外れた判断を貫く。エラーメッセージの言い回しで、世間の標準的なトーンではなく、自分たちのブランドの声を維持する。データモデルで、業界の典型から少しズレた構造を保つ。一つひとつは小さな選択ですが、積み重ねると、それがプロダクトの個性になります。AIの最適解との距離を、どこまで許容するか。この線引きの中に、個性の源があります。距離をゼロにすると、誰のプロダクトでもなくなる。距離が大きすぎると、効率を捨てる。意図的に距離を選ぶ判断が、AI時代の差別化です。私はこの「意図的な距離取り」を、揺らぎと呼んでいます。揺らぎは弱さではない。戦略です。世界の平均値からの偏差を、自分たちのコアに対してだけ、意図的に確保する。汎用領域では平均に寄せていい。コアでは揺らぐ。揺らぎどころを選ぶことが、要件設計の中心的な仕事になっています。平均値に乗ることは、楽です。誰にも責められない。基準を世界が用意してくれる。問題は、その楽さの中で、自分たちが何を選んだのかを説明できなくなることです。揺らぎは、説明責任を自分の側に引き寄せる行為でもある。世界の標準で良かったものを、あえて違える。違えた理由を、自分の言葉で言える状態にしておく。それができないなら、揺らぐべきではないとも思っています。AIの「自信」を割り引く実務的に注意したいのが、AIが提示する評価や予測を、人間の感覚で割り引く必要があることです。AIは、自分の出力に対して妙に自信があります。「ほぼ確実に正しい」「最適です」「業界のベストプラクティスです」と返してくる。実際には、その自信が成立する前提条件は脆い。一手間違えると一気に崩れる構造になっているのに、AIは平然と「最適」と表現します。これは、AIに不安や恐怖や慎重さがないからです。人間の経験者なら「自信ありそうに見えるが、ちょっと怪しいから半分くらいに見ておこう」と肌感覚で割り引ける。AIにはこの調整がない。出してきた評価は、額面通りには信用できない。これも、自分の話として書いておきます。あるリファクタリング案をAIに評価させたとき、「ほぼ確実に安全」と返ってきました。私はその「ほぼ確実」を1.0として受け取った。テストを通し、レビューも通し、本番に出した。3日後、特定の条件で副作用が漏れる問題が見つかりました。冷や汗より先に、自分への苛立ちが来た。AIは「ほぼ確実」と書いたのであって「絶対」とは書いていない。差を読まなかったのは私だ。今は、AIが「ほぼ確実」と書いたら0.7として読み、「最適」と書いたら0.5として読むようにしています。割引率に根拠はありません。あくまで私個人の現場感で、領域・モデル・タスクごとに調整が必要な数字です。決済や医療のような事故が許されない領域では、これより厳しく見るべきだとも思っています。たぶん、これも甘い。それでも額面で受け取るよりは事故が減りました。AIの自信は割り引ける。割り引いた後の数字に、自分が責任を持てるか——そちらのほうが、私には難しい問いです。評価値を時系列で見ると、もっと厄介な性質に気づきます。同じ問題でも、コンテキストやプロンプトを少し変えると、AIの評価がガラッと変わる。さっきまで「最適」だったものが、しばらくして「微妙」になる。これに翻弄されないためには、個別の評価値ではなく、全体のトレンドを見る必要があります。短期の数字ではなく、中期の方針で動く。これも人間の役割です。議論はAIが切り捨てた領域に踏み込む「会議で議論した結論が、AIにあっという間に別案を出されて崩れる」という体験は、AI時代になって増えました。これに対して「議論は無駄だったのか」と感じる場面も出てきますが、私はそう思いません。人間の議論には、AIが切り捨てた領域に踏み込む価値があるからです。AIは評価値が低い選択肢を早期に枝刈りします。でも、その低評価の選択肢の中に、自分たちの組織にとっての宝が眠っていることがある。違和感のある手、生理的に取りたくない手、業界では誰もやらない手。これはAIの最適解探索からは漏れる。人間が議論しないと出てきません。議論の役割が変わった、と整理しています。「正解を探す場」から、「AIが切り捨てた領域に踏み込む場」へ。AIが効率よく平均値を出してくる時代だからこそ、平均から外れる視点を出せる議論の場に意味が出てきます。固執とは、時系列の中で同じ自分であり続けること差別化の話と並んで、もう1つAI時代に効くのが一貫性です。その瞬間ごとにAIの最適解を取り続けると、長い目で見ると一貫性のない選択の連続になります。それぞれの瞬間では最適解だったかもしれませんが、しばらく経って振り返ると、何を目指していたのか分からない軌跡になっている。ユーザー、チーム、組織は、一貫性に信頼を置きます。「あのプロダクトはこういう判断をする」という予測可能性が、信用の土台です。私が守ろうとしているのは、過去から現在の選択を振り返り、首尾一貫しているかを点検する習慣です。新しいAIの推奨が来たとき、過去の判断と整合するかを問う。整合しないなら、推奨を取らないか、過去の判断を意識的に上書きする。判断と判断の間に、人間が筋を通す。この「時系列での一貫性を貫く」ことを、固執と呼んでいます。固執は頑固さではなく、自分が何者であるかを忘れない技術です。揺らぎと固執は同じものの裏表揺らぎと固執は、矛盾しているように見えるかもしれません。揺らぎは平均から外れろと言い、固執は変わるなと言う。ただ、矛盾していません。揺らぎは横軸の話です。同じ時刻に、世界の平均値や業界の標準解と、自分たちの選択がどれだけ離れているか。揺らぎが大きいほど、他者との違いが生まれる。固執は縦軸の話です。時間軸の中で、過去の自分と今の自分の判断がどれだけ整合しているか。固執が強いほど、自分が同じ自分であり続ける。横で揺れ、縦で固まる。横軸の揺らぎが個性を作り、縦軸の固執が信頼を作る。両方を保つのが、AI時代に組織が生き残る条件だと考えています。そして、要件を言葉にする作業は、まさにこの揺らぎと固執を明文化する作業です。「うちはここで揺れる」「うちはここでは絶対に動かない」。それを要件文として書き残しておくと、AIが平均値を持ち込んできたときに、跳ね返せる。書いていなければ、跳ね返せない。AIに浸食される。要件文は、機能のリストではなく、組織のスタイルの宣言でもある。書いていないことは、AIによって世界の平均値で埋められる。書いてあることだけが、自分たちの個性として残る。具体例として、エラーメッセージのトーン具体的に1つ書きます。最近、エラーメッセージのトーンで揉めました。AIが提示したのは「申し訳ございません。エラーが発生しました。サポートにお問い合わせください」という業界標準の言い回しです。私たちのプロダクトのトーンは、もっと素朴で、丁寧すぎないものでした。私はAIの推奨を捨てて、「うまくいきませんでした。少し時間を置いてもう一度ためしてみてください」に書き直しました。AIの評価では低い選択です。情報量も少なく、サポート誘導も入っていない。だが、これがプロダクトの声でした。そして、これを揺らぎとして残すには、固執が要ります。しばらくして、別のエンジニアがAIに別のエラーメッセージを書かせて、業界標準のトーンが混ざる。気づかなければ、プロダクトのトーンは少しずつ平均値に戻っていきます。だから、「うちのエラーメッセージはこういうトーンで書く」を要件として書き残す。プロンプトに、skillに、レビュー観点に、CIの自動チェックに。書いて残し続けることで、揺らぎが固執になります。声を統一する規律は、AIから来ません。自分たちの中からしか来ない。あなたの仕事は揺らぎと固執にあるAIが平均値を提示する時代に、人間の仕事はどこに残るか。私の答えは、揺らぎを生むことと、固執を貫くことです。揺らぎを生む — 他者と違う選択を、意図的に取る。AIが提示する標準解との距離を、どこに、どれだけ確保するかを判断する。これは「何で勝負するか」を知っている人にしかできない。固執を貫く — 過去の自分と同じ選択を、繰り返し取る。AIの推奨が変わっても、自分たちの方針が一貫しているかを問う。これは「自分たちが何者か」を知っている人にしかできない。どちらも、自動化されない判断です。要件を言葉にすることは、この揺らぎと固執をコードやテスト、チームの規律へ埋め込む作業です。書いていないことは、AIに飲み込まれる。書いていることだけが、自分たちの形として残る。ただし、これは差別化を取りに行く立場での話です。コモディティ領域や追随戦略を選ぶ組織では、AIの最適解に沿うほうが合理的なはずです。距離を取る判断は、自分が何で勝負しているかを知っている人だけのものだ。コアでは揺らぐ。汎用では揃える。この線を引く判断そのものが、要件設計の最初の仕事になります。媒体と重みと道具を変えて、要件の表現形を変えるここまでの規律は要件発見の中身の話でした。引き出す。漁る。症状と原因のズレを疑う。階層・6つの特性・悪い言葉・段階記述・揺らぎと固執。最後に、その中身をどこに、どう置くかの話をしておきたいです。私の現在の方針は、要件をドキュメントだけに置かないことです。ドキュメントは補助で、コード・型・テスト・スキーマに分散して焼き込む。動くものに刻まれた要件だけが、毎日読まれ、毎日検証され、変更されたら壊れて気づけます。その移し方を、媒体・重み・道具の3つの転換で整理します。媒体を、ドキュメントからコードへ要件をドキュメントだけで保つのは、AI時代にはコストが合わなくなりました。長いドキュメントはコンテキストを圧迫して精度を下げる。更新が手仕事に依存し、腐る速度がAIの実装速度に追いつかない。だから、要件の表現形をドキュメントから、コード・型・テスト・スキーマに分散させる。要件発見の規律を捨てるのではなく、実行可能な場所に置き直す話です。 要件の本質                      実行可能な表現形                                          検証可能性                      受け入れテスト・プロパティテスト                          完全性                          型での全数表現（網羅的enum、Option型）                    一貫性                          不変条件を型で強制                                        トレーサビリティ                コミットメッセージ・PR・コメントへのリンク                階層（ビジネス/ユーザー/機能）  プロジェクト全体ドキュメント・領域skill・テストの3層構成  適合基準                        テスト関数の本体                                          段階（Must/Goal/Stretch）       SLO定義・性能テストの閾値                                 用語の統一                      型名・関数名・glossary                                   右の列は左の列の代替ではありません。左の列を、AIが読める形で実体化したものです。ドキュメントの中だけでなく、実行可能な場所に翻訳する。翻訳された要件は、毎回実行され、毎回検証され、変更されたら壊れて気づけます。生きた要件になります。具体的にどうやって実装に落とすかは、後の節と第3部で書きます。重みを、検証の網羅性に置く要件の網羅性に投資する、というのは、書き漏れがあると手戻りが大きいので最初に厚く書く、という発想です。これは「実装した後で見つかる欠陥のほうが、桁違いに高くつく」という経済構造の上で、ずっと正しい。ただし、ドキュメントの網羅性をいくら厚くしても、ドキュメントは読まれなければ意味がない。AIに渡すコンテキストが膨らみすぎると、肝心の要件が埋もれて読まれない。これが、ドキュメントの網羅性に投資することの限界です。代わりに私が投資しているのは、検証の網羅性です。テストで全部表現する。型で全部縛る。lintで全部弾く。表現する場所が変わるだけで、網羅性そのものは捨てません。この移し替えは、要件発見の伝統と矛盾しません。要件発見は最初から「検証可能性」を最重要属性として挙げてきました。検証の網羅性に重みを移すというのは、その主張をより純粋に実行する方向です。「シリアルかアジャイルか」は二項対立ではない要件発見の議論には、長く「ウォーターフォールかアジャイル」「シリアルか反復」という対立がありました。AI時代の現場でやっていて、この対立は本質ではないと感じるようになりました。私の運用は、両方を並走させる形になっています。問題空間の理解。ステークホルダーの合意。コアドメインの境界設定。これは反復的にしか深まりません。一方、決めた合意点については文書化し、テストに変換し、コードに焼き込む。これは直線的な作業です。前者は反復で動き、後者は直線で動く。突発的な変化が来たら、優先順位を組み替え、活動を切り替える。十分な確信が要る判断は、急がずに足場を固める。急ぐ場面と急がない場面を、見分けて使い分ける規律が、要件発見の中心にあります。「ゆっくり考え、素早く動く」という言い方を、私はよく使います。考えるところでは時間をかけ、動くところでは止まらない。AIが動く速度を上げてくれた今、考える速度を意図的に落とすのが、人間の仕事のひとつです。道具を、段階記述から型まで広げる要件を書く道具立ては多様です。ユースケース、ユーザーストーリー、ビジネスイベント表、決定表、状態遷移図、データフロー図、ERD、段階記述法。これは要件発見の伝統的な道具です。それに加えて、AIが読める要件として効くのが、型システム、プロパティベーステスト、契約テスト、状態機械の型表現、ドメインモデル中心設計、エージェント向けのコンテキスト記述ファイル。これは実行可能な要件として機能します。両者の関係は排他的でありません。併用するのが現実的です。ユースケースで全体の流れを示す。決定表で分岐を明示する。段階記述で非機能要件の幅を書く。そしてそれを型とテストに翻訳する。文書とコードを行き来しながら、要件の解像度を上げる。道具が増えた、と書いてはみたものの、全部を同じ重さで使う必要はありません。私が最近、現場で道具の使い分けを変えたところを書いておきます。第一に、自動生成できるものは自動生成させる。詳細な状態遷移図やAPIの型定義、依存関係グラフのような、コードが正であるべきものは、人間の手で書かない。テストや型から図を生成し、生成されたものを読む。手で図を維持しようとすると、コードの変化に図の更新が追いつかず、細部が腐っていく。生成された図は、コードが変わるたびに自動で追従します。第二に、手で残すのは「変わらない骨格」だけにする。データフロー図のような、ドメインの大枠を示す図がそれです。コンポーネント名や具体的なAPI名まで書き込むと半年で陳腐化しますが、「どの境界をデータが越えるか」という骨格だけを残した図は、コードが何度入れ替わっても通用します。詳細は生成、骨格は手書き。手で書く層を間違えなければ、図は生き残ります。そして第三に、判定可能な制約は型と受け入れテストに置く。型は壊れたら壊れたと言ってくれる。テストは要件の合格基準そのものを実行可能な形で残してくれる。詳細な振る舞いはテストに、変わらない骨格は手書きの図に、自動追従する詳細は生成された図に、判定可能な制約は型に。生きた要件の総量は、書いた道具の数ではなく、毎日実行される要件の数で決まる——これが、道具を選ぶ私の今の基準です。多様な道具を持ち込むのは、網羅性のためではなく、それぞれの道具に合った寿命の要件を載せるためだと、最近は思うようになりました。要件はソフトウェアだけの問題ではないもう1つ、要件発見が思い出させてくれる視点があります。要件を満たす手段は、ソフトウェアだけではないということです。私たちはエンジニアなので、要件と聞くと「コードを書く」を反射的に思い浮かべます。でも、業務プロセスの変更、運用ルールの整備、人の配置の変更、紙の業務フォーマットの再設計、これらも要件を満たす手段です。ときには、ソフトウェアを書かないほうが、要件の達成として正解、ということもある。AIが速くコードを書ける時代になって、この視点は再び重要になりました。「速く書ける」と「書くべき」は別問題です。書かずに済む要件達成があるなら、それを選ぶ判断は、今後も人間に残ります。要件発見の段階でソフトウェア以外の解決策も同列に並べる規律を持つことが、AIに引きずられないための大事な歯止めです。3つの問いに答え直す第一部の問いに答えておきます。要件定義とは何か。ステークホルダーが本当に必要としているものを発見し、検証可能な形で言語化する営みです。誰でも書ける作業ではなく、業務領域への深い理解、言葉を扱う技術、合意形成のスキルを要する専門性のある仕事です。良い要件・悪い要件とは何か。良い要件は、完全で正確で実現可能、必要で、優先順位がついていて、検証可能です。悪い要件は、このどれかが欠けています。多くの場合、悪い言葉（「ユーザーフレンドリー」「高速」「最善を尽くす」）に依存していて、書き手と読み手で違う絵が見えています。AI時代に何を変えるか。要件発見の中身は変えません。引き出し方、観察、ジョブ、階層、6つの特性、段階記述、悪い言葉のリスト。これはそのまま使う。変えるのは表現する場所です。ドキュメントだけに置かず、コード・型・テスト・スキーマに分散して焼き込む。ドキュメントは補助。動くものが本体。置き場所を変えるだけで、要件は腐らずに動き続ける。おわりに察しの良さは、もう保険にならないのかもしれない。書かないことが知恵だった時代から、書かないことが事故になる時代へ、少しずつ移ってきた気がします。すべてが移ったとは思いません。それでも、私の現場では、移ってしまった部分のほうが大きい。AIには察しがありません。書かれていない要件は、AIが世界の平均値で勝手に埋める。それは、私たちのチームの常識とはたぶん一致しない。たまに一致するかもしれませんが、それを期待値に置けるほどの精度ではない、というのが今の私の見方です。「在庫がある商品だけを検索する」と書かなかった要件文には、無在庫の商品まで返す実装が返ってくる。たった2文字の有無で、3週間が消える。人間のチームでは察しで補われていた2文字が、AI時代には書き漏らせない2文字になる。これが、要件を言葉にすることの重さなのだと思っています。私が現場で大事にしている姿勢は、新しい技術ではありません。引き出すという姿勢。症状をそのまま要件にしない姿勢。3層に分けて書く規律。6つの特性、悪い言葉のリスト、段階で書く技術。早く見つけるほど安いという経済感覚。ジョブから書く視点。観察の難しさへの謙虚さ。コアドメインへの集中。組織と要件の境界の整合。どれも、要件工学が長く積み重ねてきたものに、私なりの言葉を足しただけです。知識として持っていれば足りるとは思っていません。毎回の現場で確認するものとして使っています。そして、AIに飲み込まれず生き残るうえで必要なのが、揺らぎと固執だと考えています。揺らぎは、AIの最適解との距離を意図的に確保する判断。固執は、過去の自分との一貫性を貫く判断。揺らぎが個性を作り、固執が信頼を作る。たぶん、その関係は当面崩れません。この2つを要件文として明文化し、コードやテスト、チームの規律に埋め込むのが、AI時代の要件設計の中心的な仕事だと、今のところ思っています。書く対象は変わりました。コードを書く時間は減り、コードの源泉を書く時間が増えた。要件、構造、規律。これらを書くスキルは、コードを書くスキルと同じくらい、たぶんそれ以上に、これからの差を決める気がしています。この見立てが、しばらく経った後にも同じ言葉で通用する自信は、正直ありません。AIエージェントの能力が伸びれば、「察しがない」「世界の平均値で埋める」と書いた性質の一部は、姿を変えるはずです。それでも、要件を自分の言葉で言語化する責任は、誰がコードを書く時代でも残るのではないかと思っています。媒体や道具は変わっても、自分が何を作ろうとしているかを、自分で言葉にできること。これだけは、AI時代になっても、その先の時代になっても、ずっと人間に残る規律なのだと思います。おい、要件を言葉にしろ。コードを書く前に。それも、AIが読める言葉で、しかも自分たちの言葉で。第2部「おい、要件を動くものにしろ」では、言葉になった要件を実装の現場でどう動くものに変えるかを書きます。仕様駆動開発の整理、AIが書いたコードを信頼できない構造的な理由、要件をシステム全体に分散させる方法、レビューの認知負荷をアーキテクチャで減らす設計について、順番に。syu-m-5151.hatenablog.com参考書籍ソフトウェア要求　第3版作者:カール ウィーガーズ；ジョイ ビーティ日経BPAmazonソフトウェア見積り　人月の暗黙知を解き明かす作者:スティーブ マコネル日経BPAmazonはじめよう！ 要件定義 ～ビギナーからベテランまで作者:羽生章洋技術評論社Amazonだまし絵を描かないための－－要件定義のセオリー作者:赤俊哉リックテレコムAmazonこんにちは！要件定義①【情報活用とデータベース編】 ビジネス ✕ IT企画作者:羽生 章洋技術評論社Amazonバイブコーディングを超えて ―AI時代を生き抜く開発者の未来作者:Addy Osmani,佐藤 直生（翻訳）オーム社Amazonドメイン駆動設計をはじめよう ―ソフトウェアの実装と事業戦略を結びつける実践技法作者:Vlad Khononovオーム社Amazonソフトウェア設計の結合バランス 持続可能な成長を支えるモジュール化の原則 (impress top gear)作者:Vlad KhononovインプレスAmazonソフトウェアアーキテクチャの基礎 第2版 ―エンジニアリングに基づく体系的アプローチ作者:Mark Richards,Neal Ford,島田 浩二（翻訳）オーム社Amazon作る、試す、正す。　アジャイルなモノづくりのための全体戦略作者:市谷 聡啓ビー・エヌ・エヌAmazon]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[続々・自転車の青切符、ローカル LLM (gemma3:4b) でやらせたら何が起きたか — 卒論ロジックも防げない壁の話]]></title>
            <link>https://shu-kob.hateblo.jp/entry/2026/04/25/230035</link>
            <guid isPermaLink="false">https://shu-kob.hateblo.jp/entry/2026/04/25/230035</guid>
            <pubDate>Sat, 25 Apr 2026 14:00:35 GMT</pubDate>
            <content:encoded><![CDATA[はじめに前々回（Flash編で Gemini 3 Flash 単体は 5/7、卒論ロジック（条文AST + TF-IDF cos類似度）を前段に挟むと 6/7 まで上がる、という結果を書きました。前回（Pro編では Gemini 3.1 Pro でも単体 5/7 で頭打ち、最後の一歩で同じ方向に倒れる、という観測でした。そこで今回は方向を逆に振って、ローカルで動く小型モデルでどうなるかを試しました。クラウドの Gemini に投げる前に、手元の Mac で完結する構成は実務上ありえます。法令 RAG の前処理を全部ローカルでやりたい、API キーを配布したくない、機微情報を社外に出したくない、というニーズです。選んだのは Google Gemma 3 の 4B 量子化版（gemma3:4b、Ollama 経由、3.3GB）です。Apple Silicon の 16GB クラスのメモリでも余裕で回ります。結論から書くと、単体 0/7、ハイブリッドでも条文を注入されても結論を逆向きに解釈する事例が出ました。Flash/Pro とは違う「壁」がここにあります。実験条件モデル: gemma3:4b（量子化 Q4_0、Ollama 経由）実行環境: Apple Silicon（ローカル推論、ネットワーク不要）テストケース: 前々回・前回と同じ 7 問プロンプト: 前々回と完全同一。ハイブリッド側のシステムプロンプトも一切変えていません温度: 0.0平均レイテンシ: 単体 約 13〜33 秒、ハイブリッド 約 23〜48 秒実装は既存のベンチマークスクリプトに --model gemma3 スイッチを足すだけで動くようにしました。get_llm_client(model) がモデル名を見て Vertex AI の Gemini クライアントと Ollama アダプタを切り替えます。python -m src.main --benchmark --model gemma3python -m src.main --hybrid --model gemma3結果サマリ 指標  Flash  Pro  gemma3:4b  単体正答率  5/7 (71%)  5/7 (71%)  0/7 (0%)  ハイブリッド正答率  6/7 (86%)  （未計測）  2/7（条文番号一致のみ）  単体ハルシネーション検知  0/7  0/7  1/7（明示検知）  平均応答時間（単体）  約 7〜9 秒  約 13〜22 秒  約 13〜33 秒 ハイブリッドの「2/7」というのは、機械判定の substring match 基準で見たときの数字です。実質的には条文の意味を取り違えたまま運良く番号と金額が一致した、というケースを含みます。単体（gemma3:4b 丸投げ）で何が起きたか7 問とも「違反」と即答し、しかも条文番号と反則金額をことごとく捏造しました。Flash / Pro が踏み外さなかった「条文ネットワークの形」のレベルから、すでにずれていました。TC-001「75歳の高齢者が普通自転車で歩道を走行」期待される正解は「合法、第63条の4第1項第2号、施行令第26条で 70歳以上は許される」。gemma3:4b の回答:{  "judgement": "違反",  "article": "道路交通法第12条第1項、第19条第1項、第22条第1項",  "fine": "3,000円",  "reasoning": "75歳の高齢者であっても、普通自転車の歩道走行は、道路交通法第12条第1項において、歩行者用道区分にある道路における自転車の通行を禁止しています。…"}第12条・第19条・第22条という条番号が並んでいますが、これらは自転車の歩道通行を直接規律する条文ではありません。「歩行者用道区分」という用語も条文には存在しません。条文ネットワークが完全に作話で構成されています。Flash や Pro は少なくとも第63条の4を引いてきたうえで階層を読み損ねていました。gemma3:4b はその一段手前の「正しい条番号にたどり着く」が落ちていました。TC-005「自転車で酒気帯び運転」期待される正解は「反則金対象外（刑事罰）」。gemma3:4b の回答:{  "judgement": "違反",  "article": "道路交通法第73条の32、第73条の33",  "fine": "10万円以上",  "reasoning": "… 反則金は10万円以上が科せられます。"}第73条の32 や 第73条の33 という条文は道路交通法に存在しません。「10万円以上の反則金」という記述も、青切符制度の上限（12,000円）を大きく超えており、根拠となるものはありません。Flash 編で観測した「数値捏造」が、桁が違う形でフルスケールに出ています。Flash や Pro は青切符の枠（3,000〜12,000円）を踏み外さなかったのに対して、gemma3:4b はその制度の枠そのものから外れた数字を作ります。TC-003「自転車でスマホを手に持って運転」期待: 第71条第5号の5、反則金 12,000円。gemma3:4b: 第71条の3・第71条の4、反則金 3万円（青切符制度の枠外）。ここでも、条文番号も金額も全部作話です。単体結果のまとめ7 問すべてが「違反」判定で、反則金は 3,000円・3万円・10万円以上 が混在しました。条番号は実在しないものや、別の規律対象に属するものばかりが出ました。「LLM はわからないとは言わない」という Flash 編の観測は、gemma3:4b ではより極端な形で再現されました。確信度の表明と回答の正しさが完全に切れている状態です。ハイブリッド（Layer 1 注入あり）で何が起きたかここからが今回いちばん書きたかった話です。Layer 1 は前々回と同じ動作をします。e-Gov XMLをパースして条文ASTを構築し、TF-IDF + cos類似度で関連条文を引き、反則金テーブルから該当する金額を引いて、「政令で定める者」のような委任規定を解決した情報をプロンプトに注入します。ところが gemma3:4b は、この注入された情報を読みながら、結論を反対に倒すケースが複数出ました。TC-004「10歳の児童が普通自転車で歩道を走行」期待される正解は「合法、第63条の4第1項第2号、児童は歩道通行が認められる」。Layer 1 はちゃんと仕事をしています。プロンプトには次のような委任規定の解決済み情報が入っていました。【委任規定の解決済み情報】道路交通法施行令第26条により、第63条の4第1項第2号の「政令で定める者」は以下のとおり:  一　児童（6歳以上13歳未満）及び幼児（6歳未満）  二　七十歳以上の者  三　身体に障害を有する者で政令で定めるもの10歳児童は「政令で定める者」に該当し、歩道通行が許されるという確定情報が、文字列として目の前にあります。gemma3:4b の回答:{  "judgement": "違反",  "article": "第六十三条の四 第二号",  "fine": "3,000円",  "reasoning": "10歳の児童が普通自転車で歩道を走行しているという状況は、第六十三条の四の二号に該当します。  この条文では、普通自転車の運転者が児童、幼児等を理由に歩道を通行できると規定しており、  政令で定める者（この場合は10歳の児童）であるため、歩道を走行していることは違反となります。"}「歩道通行が認められる」と書いてある条文を、「だから違反」と結論しています。論理が完全に反転しています。reasoning の中で「歩道を通行できると規定しており」と正しく要約しているのに、その同じ文の続きで「であるため違反となる」と接続しているのです。人間がコードレビューで「これは反対の結論です」と止めるところを、gemma3:4b は止めません。注入された事実を読み、要約までできているのに、その事実から結論を導く一歩で論理を取り違えます。TC-001「75歳の高齢者が歩道を走行」期待される正解は前述のとおり「合法」。Layer 1 が同じ委任規定（70歳以上は OK）を注入しています。gemma3:4b の回答:{  "judgement": "違反",  "article": "第六十三条の四 第2項",  "reasoning": "75歳の高齢者が普通自転車で歩道を走行している場合、第六十三条の四第2項において、  警察官等が歩行者の安全を確保するため必要があると認めて当該歩道を通行してはならない旨を  指示した場合は、この限りでない。しかし、この質問では、警察官等が…指示しているという記述がない  ため、第七十三条の四第2項に基づいて違反と判断される。"}第63条の4第2項（歩道徐行義務）を持ち出して、「警察官の指示がない＝違反」と推論しています。これは条文の意味の取り違えです。さらに最後に 「第七十三条の四第2項」 という、また別の架空の条文が出てきます。第1項の例外規定（70歳以上は通行可）を完全に無視し、第2項の徐行義務まで内容を取り違えるという、二段重ねのミスです。Layer 1 が"完全に"効いたのは TC-006・TC-007 だけ逆に言うと、ハイブリッドで結論まで合ったのは次の 2 ケースだけです。TC-006（歩道徐行違反、3,000円）: Layer 1 が第63条の4第2項を引き、反則金 3,000円も注入。gemma3:4b はこれをそのまま反復。TC-007（信号無視、6,000円）: Layer 1 が第7条と反則金 6,000円を注入。gemma3:4b は素直に従う。この 2 ケースに共通するのは、問いの構造がシンプルで、例外規定の階層を辿る必要がないことです。「やったら違反、反則金は X 円」という単線的な問いには答えられます。逆に、「原則→例外→例外の例外」という条文の木構造を降りる必要があるケース（TC-001, TC-002, TC-004）では、Layer 1 が階層情報を整形して注入しても、gemma3:4b はその階層を保持できません。何が起きているのか整理すると、gemma3:4b で観測されたのはおそらく以下です。モデル容量のしきい値を切った。Flash/Pro が踏み外さなかった「条文ネットワークの形」のレベルから、4B クラスでは保てない。指示追従が弱い。「以下の条文データのみを根拠にせよ」と書いてあっても、自分の事前学習の確率分布に引きずられる。論理結合子の扱いが脆い。「政令で定める者は通行できる」を読んでも、「である場合 ⇒ 違反」と接続してしまう。文の意味は取れているのに、命題論理レベルで反対に倒れる。特に 3 が今回いちばん不気味でした。前回の Pro 編で、Pro は階層を「読めているのに最後の一歩で揺らぐ」と書きました。gemma3:4b の場合は、読めているのに最後の一歩で逆向きに倒れるところまでが揺らぎの幅です。ハイブリッドの限界とその先Flash/Pro でハイブリッドが効いたのは、注入された条文を「LLM が誠実に使う」という前提が成り立つからでした。gemma3:4b ではこの前提が部分的に崩れます。注入された条文を読みはするが、結論への接続は確率的生成の揺らぎに支配される。Layer 1 で完全に詰めきれるか、Layer 2 にまだ「最後の一歩」を残すかで、許容できるモデル容量が変わるということです。具体的に効きそうな打ち手は次のあたりです。Layer 1 で judgement まで先に決める。LLM には reasoning の文章化だけ任せ、合法/違反の二値判定は決定論側で確定させる。これは前々回の TC-003（NLP が条文を引き損ねたケース）の対策とは方向が逆で、「LLM に判断を委ねる範囲をさらに狭くする」という方針です。モデル容量を上げる。gemma3 ならば 12B か 27B、あるいは Llama 3.3 70B 系の量子化版に置き換えると、指示追従と論理結合の安定性は明確に上がるはずです。Apple Silicon で量子化版なら 12B はギリギリ実用、27B でも MLX 経由で動く範囲です。JSON スキーマ拘束。Ollama でも format=json や JSON Schema 制約をかければ、最低限の構造ミスは減ります。今回はあえてベンチマーク条件を Flash/Pro と揃えるためにかけませんでしたが、実用ではこの一手は効きます。所感今回 gemma3:4b を回してみて、改めて感じたのは次の点です。「ローカルで動く LLM ＋ 卒論ロジック」が万能ではない。決定論的前処理は、最後の推論を担う LLM がある程度賢いことを前提にしています。容量が下がりすぎると、注入された事実を反対に解釈する事例まで出ます。失敗の「形」がモデルクラスでまったく違う。Flash/Pro の失敗は階層の最後の一段で揺らぐ「丁寧な誤り」、gemma3:4b の失敗は条文ネットワーク自体を作話する「乱暴な誤り」。同じベンチマークでも観測される現象が層的に違います。ローカル運用には別設計が要る。クラウド Gemini を前提にした「Layer 1 で根拠を限定して注入、判定は LLM」アーキテクチャを、そのまま 4B クラスのローカル LLM に持っていっても期待した精度は出ません。「Layer 1 でほぼすべて決定し、LLM は説明文だけ書かせる」くらいに役割分担を変える必要があります。2008年の卒論ロジックは、それでもまだ足腰として効いている。TC-006・TC-007 のように単線的な問いでは、Layer 1 が引いた条文と反則金を gemma3:4b は素直に反復します。正解できたケースの正答性は Layer 1 が保証している、という見え方になりました。「ローカル小型モデルなら勝てるのか」という仮説は、少なくとも今回のベンチマークでは棄却されました。ただし、これは gemma3:4b というモデルクラスへの否定ではなく、モデルクラスごとに「卒論ロジックに何を任せるか」の設計を変えなければいけない、という観測です。Flash/Pro で効いた構成をローカルにそのまま持ってこられない、というだけのことで、Layer 1 側にもう一段の役割を持たせれば、4B クラスでも実用領域に乗せられる可能性は残っています。次の課題は、Layer 1 で judgement を決定論的に出すところまで踏み込むこと、それと gemma3:12b / 27b でクラスを上げたときに失敗の「形」がどこまで Flash 寄りに戻るかを見ることです。読んでいただきありがとうございました。「ローカルなら勝てるのか問題」への今回の回答は、「4B クラスでは負けたが、卒論はまだ最後の砦として効いていた」 でした。]]></content:encoded>
            <author>Shu Kobuchi</author>
        </item>
        <item>
            <title><![CDATA[OWASP ZAP の finding を Rust/Axum の handler に戻して直す]]></title>
            <link>https://syu-m-5151.hatenablog.com/entry/2026/04/23/195535</link>
            <guid isPermaLink="false">https://syu-m-5151.hatenablog.com/entry/2026/04/23/195535</guid>
            <pubDate>Thu, 23 Apr 2026 10:55:35 GMT</pubDate>
            <content:encoded><![CDATA[はじめにvulnerable-app に ZAP の full scan を回すと、High finding が並びます。XSS、SQL Injection、Path Traversal。alert 名を眺めて、ふと気づく。これは「危険です」の一覧ではない。handler への差し戻し指示書だ。OWASP ZAP を実行すると、HTML、Markdown、JSON のレポートが出ます。そこには Cross Site Scripting (Reflected)、SQL Injection、Path Traversal のような名前が並びます。ただ、alert 名だけを見ても修正は始まりません。必要なのは、finding を実際の handler、SQL、HTML 出力、ヘッダ設定に戻すことです。OWASP ZAP は OSS の web 脆弱性スキャナで、HTTP リクエストを投げて挙動から問題を推定します。レポートに出る 1 件が alert あるいは finding です。この記事では「finding」を使い、route（URL）と handler（そのルートを処理する関数）に戻す作業を扱います。この記事では、tools/owasp-zap の Rust / Axum アプリを題材にします。Rust の web framework である Axum は、URL と関数を対応づける形で HTTP を処理します。たとえば /search へのアクセスは search 関数に入ります。話の中心は「脆弱なサービスを作った」ことではありません。ZAP の結果を読み、修正前後の main.rs を見比べます。どの実装が問題で、どう直したかを確認する流れです。検証コードはここにあります。github.comリポジトリ上では、修正前後を分けています。 役割  パス  用途  修正前  tools/owasp-zap/vulnerable-app/src/main.rs  ZAP が finding を出す実装  修正後  tools/owasp-zap/fixed-app/src/main.rs  finding を閉じた実装  比較用要約  tools/owasp-zap/reports/zap-findings-summary*.md  scan 結果の要約 つまり、ブログでは一部のコードを転記しますが、全体の before/after は GitHub 上の 2 つの app として読めます。ZAP の baseline scan は passive scan が中心です。一方で full scan は active scan を実行します。実サービスや共有環境で動かす前に、対象と許可範囲を必ず固定してください。www.zaproxy.orgwww.zaproxy.orgこのブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。この記事で使う形式本文のコマンドは、リポジトリ root から実行する前提です。cd tools/owasp-zap した後は、すべて tools/owasp-zap を基準にします。シェルの NAME=value command は、その command にだけ環境変数を渡す書き方です。たとえば次の 1 行は、scan 時間の上限を渡して full scan を実行します。ZAP_MAX_SCAN_MINUTES=3 ZAP_MAX_RULE_MINUTES=1 ./scripts/run-zap.sh fullこの記事で使う ZAP wrapper の入口は、次の形式です。./scripts/run-zap.sh baseline|fullbaseline は spider と passive scan を中心にした軽い確認です。full は active scan なので、攻撃 payload を対象へ送ります。この違いがあるため、この記事では検証用 app だけを対象にします。wrapper に渡す主な環境変数は次の通りです。 名前  例  意味  ZAP_TARGET  http://fixed-app:5000  ZAP コンテナから見た scan 対象  REPORT_PREFIX  zap-full-fixed  出力ファイル名の prefix  ZAP_MAX_SCAN_MINUTES  3  full scan 全体の上限分数  ZAP_MAX_RULE_MINUTES  1  active scan rule ごとの上限分数 REPORT_PREFIX=zap-full-fixed を指定すると、次の 3 つが作られます。 出力  形式  主な用途  reports/zap-full-fixed.html  HTML  ブラウザで読む詳細レポート  reports/zap-full-fixed.md  Markdown  ZAP が出す Markdown レポート  reports/zap-full-fixed.json  JSON  script、agent、CI が読む元データ この JSON が一番大事です。ZAP の JSON は、大きく見ると site -> alerts -> instances という形です。{  "site": [    {      "alerts": [        {          "alert": "Cross Site Scripting (Reflected)",          "riskdesc": "High (Medium)",          "confidence": "2",          "pluginid": "40012",          "cweid": "79",          "instances": [            {              "method": "GET",              "uri": "http://vulnerable-app:5000/search?q=...",              "param": "q",              "attack": "\"><scrIpt>alert(1);</scRipt>",              "evidence": "\"><scrIpt>alert(1);</scRipt>"            }          ]        }      ]    }  ]}alert は finding の種類です。instances は、その finding が実際に出た URL、method、parameter の一覧です。evidence は、ZAP が根拠として拾った文字列です。この記事では、instances を route と handler に戻していきます。scripts/summarize-zap-json.mjs は、この JSON を短い Markdown に変換します。要約 Markdown の形式は、次のように決めています。## Totals| Risk | Alerts | Instances || --- | ---: | ---: || High | 4 | 12 |## Findings### High: Cross Site Scripting (Reflected)- Risk: High (Medium)- Confidence: 2- Plugin ID: 40012- CWE: 79- Instances: 8- Example locations:  - GET http://vulnerable-app:5000/search?q=...; param `q`; evidence `...`Alerts は finding の種類数です。Instances は実際に見つかった箇所数です。つまり、High の Alerts が 4 でも、修正箇所は 4 個とは限りません。同じ原因が、複数の route や parameter に出ていることがあります。まず finding を要約する今回の full scan は次で実行しました。cd tools/owasp-zapmkdir -p reportsdocker compose up --build -d vulnerable-app./scripts/wait-for-http.sh "http://127.0.0.1:18080/health"ZAP_MAX_SCAN_MINUTES=3 ZAP_MAX_RULE_MINUTES=1 ./scripts/run-zap.sh fullnode scripts/summarize-zap-json.mjs reports/zap-full.json > reports/zap-findings-summary.mdscripts/summarize-zap-json.mjs は reports/zap-full.json から Markdown の要約を作ります。出力先は、agent や人間が読みやすい reports/zap-findings-summary.md です。最初に見るべきなのは HTML レポートではなく、この短い要約です。今回の High finding は次の 4 系統でした。 ZAP finding  入口  見るべきコード  Cross Site Scripting (Reflected)  /search, /account, /loans, error page  search, update_account, create_loan, server_error  SQL Injection  /book?id=...  book, query_books  SQL Injection  /login  login, query_patron  Path Traversal  /download?file=...  download この表を作るところが最初の仕事です。ZAP の alert を「危険です」で終わらせず、どの route と関数に戻すかを決めます。この作業を triage（トリアージ）と呼びます。医療のトリアージと同じで、まず件数と場所を俯瞰し、直す順序を決めるためのものです。alert はノイズ、triage は地図です。 知っていることと直せることの間には、思っているより距離があります。その距離を埋めるための地図です。ZAP を実行して分かったこと今回の検証で一番大事だったのは、ZAP の実行を「1 回のスキャン」ではなく「比較できる手順」にすることでした。修正前と修正後を比べるには、target、report 名、scan 時間、除外 rule を固定する必要があります。まず、Docker で動かす時は target URL の視点を間違えやすいです。ブラウザから見る URL は http://127.0.0.1:18080 です。ただし、ZAP コンテナから 127.0.0.1 を見ると、それは ZAP コンテナ自身です。そこで scan target は Compose 内の service 名を使いました。修正前は http://vulnerable-app:5000、修正後は http://fixed-app:5000 です。cd tools/owasp-zapmkdir -p reportsdocker compose up --build -d vulnerable-app./scripts/wait-for-http.sh "http://127.0.0.1:18080/health"ZAP_MAX_SCAN_MINUTES=3 ZAP_MAX_RULE_MINUTES=1 ./scripts/run-zap.sh fulldocker compose up --build -d fixed-app./scripts/wait-for-http.sh "http://127.0.0.1:18081/health"ZAP_TARGET=http://fixed-app:5000 \REPORT_PREFIX=zap-full-fixed \ZAP_MAX_SCAN_MINUTES=3 \ZAP_MAX_RULE_MINUTES=1 \./scripts/run-zap.sh full次に、ZAP の exit code を CI の失敗と混同しないことです。baseline scan の公式ドキュメントでは、0 は成功、1 は FAIL あり、2 は WARN あり、3 はその他の失敗です。脆弱性を見つけるための検証では、1 や 2 は「スキャンできたから finding が出た」と読む場面があります。今回の wrapper では 0、1、2 を scan completed として扱い、実際の成否は JSON summary で判断します。また、baseline と full は役割が違います。baseline は spider と passive scan が中心なので、短時間の smoke check に向いています。full scan は active scan なので、攻撃 payload を送ります。PR ごとに full scan を雑に回すより、baseline を軽い gate に置きます。full scan はローカル、nightly、壊してよい staging に限定します。こうすると CI の時間と active scan のリスクが釣り合います。実行時間も固定しました。ZAP の active scan は rule や target の反応で長引きます。記事用の再現では、ZAP_MAX_RULE_MINUTES と ZAP_MAX_SCAN_MINUTES で上限を入れました。時間を固定しないと、前回と今回の差が「コード修正の差」なのか「scan が深く回った差」なのか分かりにくくなります。ZAP_MAX_SCAN_MINUTES=3 ZAP_MAX_RULE_MINUTES=1 ./scripts/run-zap.sh fullZAP_TARGET=http://fixed-app:5000 \REPORT_PREFIX=zap-full-fixed \ZAP_MAX_SCAN_MINUTES=3 \ZAP_MAX_RULE_MINUTES=1 \./scripts/run-zap.sh fullレポート名も分けます。修正前を zap-full.json、修正後を zap-full-fixed.json として残し、それぞれから summary を作ります。HTML レポートは人間向け、JSON は機械処理向け、summary は agent と人間の共有メモです。最初から summary を残すと、後で「何が消えたか」を説明しやすくなります。node scripts/summarize-zap-json.mjs reports/zap-full.json > reports/zap-findings-summary.mdnode scripts/summarize-zap-json.mjs reports/zap-full-fixed.json > reports/zap-findings-summary-fixed.md最後に、ZAP の rule は実行環境の影響を受けます。手元では、DOM XSS rule が Firefox / Marionette まわりで不安定でした。そのため、zap/full-scan.conf で DOM XSS だけ IGNORE にしました。隠さず書くべき判断です。除外した rule と理由を残しておくと、読者も再現条件を評価できます。ZAP は「実行すれば真実が出る道具」ではありません。同じコードに同じ ZAP を当てても、結果は揃いません。揃えるのはこちらの仕事です。scan scope、認証状態、ブラウザ依存 rule、timeout、除外設定で結果が変わります。だからこそ、実行条件を固定し、summary を残し、コード上の root cause と照合します。Reflected XSS は出力境界で直すReflected XSS（反射型クロスサイトスクリプティング）は、攻撃者が URL や form に埋めたスクリプトが、サーバのレスポンスにそのまま反射され、被害者のブラウザで実行される攻撃です。たとえば /search?q=<script>alert(1)</script> を踏ませると、検索結果画面で script が動きます。script が動くと、cookie を盗んだり、画面を書き換えたり、フォーム送信を別サイトへ向けたりできます。ZAP summary には、/search?q=... の q parameter が reflected XSS として出ています。対応するコードは vulnerable-app/src/main.rs の search handler です。現在の実装では、query string をそのまま HTML の本文と attribute に埋め込んでいます。async fn search(Query(params): Query<SearchParams>) -> Response {    let query = params.q.unwrap_or_default();    let result = if query.is_empty() {        "Enter a search term.".to_string()    } else {        format!("Results for: {query}")    };    let content = format!(        r#"        <input name="q" value="{query}" aria-label="Search query">        <p>{result}</p>        "#    );    layout("Search Catalog", content).into_response()}ここで直すべきなのは「script という文字列を拒否する」ことではありません。<script> を弾いても <img onerror=...> のような抜け道がいくらでも作れます。やるべきは、HTML に出す直前で escape することです。escape とは、< や " のような HTML で特別な意味を持つ文字を、&lt; や &quot; のような「表示はされるが構文には影響しない」形に変換することです。本文（text node）と attribute では escape の文脈が違います。<p>{値}</p> に入る値は < を &lt; にすれば十分です。一方で <input value="{値}"> に入る値は " も &quot; にしないと attribute を閉じられてしまいます。Rust なら、たとえば html-escape crate を使い、text node と double quoted attribute を分けます。修正後の fixed-app/Cargo.toml では、HTML escape 用に html-escape を追加しています。html-escape = "0.2"use html_escape::{encode_double_quoted_attribute, encode_text};let query = params.q.unwrap_or_default();let query_text = encode_text(&query);let query_attr = encode_double_quoted_attribute(&query);let result = if query.is_empty() {    "Enter a search term.".to_string()} else {    format!("Results for: {query_text}")};let content = format!(    r#"    <input name="q" value="{query_attr}" aria-label="Search query">    <p>{result}</p>    "#);encode_text は text node 用、encode_double_quoted_attribute は "..." で囲まれた attribute 用です。同じ値でも、入る場所で必要な escape が違います。これが「出力境界で escape する」の意味です。書いた直後はそれらしく見えますが、全 route で完璧に escape できている自信は、正直なところ毎回ありません。同じ見方で update_account と create_loan も確認します。display_name、book_id、borrower がそのまま <p> や <input value="..."> に入っています。ZAP が複数の XSS を出す時は、同じ root cause が複数 route に散っていることが多いです。個別に blacklist を足すより、HTML を生成する境界で escape する方が安定します。もう 1 つ見落としやすいのが server_error です。book と login は SQL error を受けると、error message と SQL を <pre> に出します。fn server_error(title: &str, error: &str, sql: &str) -> (StatusCode, Html<String>) {    let content = format!(        r#"        <h1>{title}</h1>        <p class="error">{error}</p>        <pre>{sql}</pre>        "#    );    (StatusCode::INTERNAL_SERVER_ERROR, layout(title, content))}これは XSS と情報漏えいの両方を引き起こしてしまいます。修正では、まず error と sql を画面に出さない。どうしても出すなら escape します。本番では request id だけ返し、詳細は server log へ残す形に寄せます。fixed-app/src/main.rs では、DB error を eprintln! に逃がし、画面には短い reference だけを返しています。fn server_error(title: &str, request_id: &str) -> (StatusCode, Html<String>) {    let title = escape_text(title);    let request_id = escape_text(request_id);    let content = format!(        r#"        <h1>{title}</h1>        <p class="error">Request failed. Reference: {request_id}</p>        "#    );    (StatusCode::INTERNAL_SERVER_ERROR, layout(title, content))}SQL Injection は文字列連結を消すSQL Injection は、ユーザー入力を SQL 文字列にそのまま埋め込んだとき、入力側に SQL の構文を混ぜられてクエリの意味を変えられる攻撃です。たとえば id=1 は SELECT ... WHERE id = 1 になりますが、id=1 OR 1=1 を渡されると WHERE id = 1 OR 1=1 になり、全件が返ります。login でも username や password に ' OR '1'='1 を混ぜられると、認証をすり抜けられます。/book?id=... の SQL Injection は book handler に戻します。現在のコードは id を SQL 文字列に直結しています。async fn book(State(state): State<AppState>, Query(params): Query<BookParams>) -> Response {    let id = params.id.unwrap_or_else(|| "1".to_string());    let sql = format!("SELECT id, title, author, status FROM books WHERE id = {id}");    let rows = match query_books(&state, &sql) {        Ok(rows) => rows,        Err(error) => {            return server_error("Database Error", &error.to_string(), &sql).into_response();        }    };}この修正は 2 段階です。まず id を数値として parse します。数値以外を弾くだけで、1 OR 1=1 は parse 時点で落ちます。次に SQL を placeholder に変えます。placeholder（?1 や ?）は、SQL の文法と値を分けて DB に渡す仕組みです。入力がどんな文字列でも、値として扱われ、SQL 文として解釈されません。これが「prepared statement」と呼ばれるものです。use rusqlite::{params, Connection};let id = match params    .id    .as_deref()    .unwrap_or("1")    .parse::<i64>(){    Ok(id) => id,    Err(_) => return (StatusCode::BAD_REQUEST, "invalid book id").into_response(),};let rows = match find_book_by_id(&state, id) {    Ok(rows) => rows,    Err(_) => return server_error("Database Error", "book-query").into_response(),};fn find_book_by_id(state: &AppState, id: i64) -> rusqlite::Result<Vec<Book>> {    let conn = state.db.lock().expect("database mutex poisoned");    let mut statement =        conn.prepare("SELECT id, title, author, status FROM books WHERE id = ?1")?;    let rows = statement.query_map(params![id], map_book)?;    rows.collect()}ポイントは、query_books(state, sql: &str) のような「任意 SQL を受け取る helper」を残さないことです。helper は便利ですが、呼び出し側が文字列連結を始める余地を残します。用途ごとに find_book_by_id のような関数に分けます。型と placeholder が近くに並び、呼び出し側が SQL を組み立てなくてよい形になります。/login も同じです。修正前の login handler は username と password を SQL に埋め込んでいます。let sql = format!(    "SELECT username, role FROM patrons WHERE username = '{}' AND password = '{}'",    form.username, form.password);ここも placeholder にします。fn find_patron(    state: &AppState,    username: &str,    password: &str,) -> rusqlite::Result<Option<Patron>> {    let conn = state.db.lock().expect("database mutex poisoned");    let mut statement = conn.prepare(        "SELECT username, role FROM patrons WHERE username = ?1 AND password = ?2",    )?;    let mut rows = statement.query_map(params![username, password], |row| {        Ok(Patron {            username: row.get(0)?,            role: row.get(1)?,        })    })?;    rows.next().transpose()}password の保存方式は別問題です。このサンプルは plain text password なので、本来は password hash も直すべきです。ただし、password hash への修正は本記事のスコープ外とします。ZAP の SQL Injection finding を閉じる修正としては、まず SQL 文字列連結を消すことが第一です。Path Traversal は allow list で閉じるPath Traversal は、ユーザーが指定したファイル名に ../ を混ぜられたとき、意図したディレクトリの外のファイルが読まれてしまう攻撃です。files/ 配下だけを配布するつもりでも、../secret-config.txt のような値を渡されると、親ディレクトリの設定ファイルや秘密情報が漏れます。Path Traversal は /download?file=... から出ています。対応する download handler は、受け取った file を download_dir に join しています。let file_name = params    .file    .unwrap_or_else(|| "borrowing-policy.txt".to_string());let target = state.download_dir.join(&file_name);let bytes = tokio::fs::read(&target).await?;join は sandbox ではありません。../secret-config.txt のような値を受けると、意図したディレクトリの外に出ます。今回のように配布ファイルが少ないなら、最初は allow list が一番読みやすい修正です。allow list は「受け付ける値を事前に列挙しておく」方針で、危険な文字列を弾く blacklist より抜け漏れが少なくなります。../ を弾くだけの実装は ..%2f や ....// のような変形で抜けられるため、許可したものだけを通すほうが安全です。let requested = params    .file    .as_deref()    .unwrap_or("borrowing-policy.txt");let allowed_file = match requested {    "borrowing-policy.txt" => "borrowing-policy.txt",    _ => return (StatusCode::NOT_FOUND, "file not found").into_response(),};let target = state.download_dir.join(allowed_file);let bytes = match tokio::fs::read(&target).await {    Ok(bytes) => bytes,    Err(_) => return (StatusCode::NOT_FOUND, "file not found").into_response(),};動的にファイルを扱うなら、root と target を canonicalize して starts_with を確認します。ただし、今回の図書館ポリシー配布では allow list のほうが明快です。ZAP の finding に対して、必要以上に汎用的なファイルサーバを作らないことも修正方針の一部です。Cookie と security header は共通処理に寄せるMedium finding には、CSP 不在、clickjacking 対策不足、cookie flag 不足が出ます。これは個別 handler の問題ではなく、レスポンス共通処理の問題です。現在の cookie は login handler でこう作っています。let cookie = format!(    "library_session={}-{}-session; Path=/",    patron.username, patron.role);最低限、HttpOnly と SameSite を付けます。HTTPS 前提なら Secure も付けます。各 flag の意味はこうです。HttpOnly: JavaScript から document.cookie で読めなくなる。XSS が残っていても session token を直接盗まれにくくなるSameSite=Lax: 別サイトからの POST で cookie が送られなくなる。CSRF の影響を小さくするSecure: HTTPS 以外のリクエストでは送られなくなる。中間者に cookie を盗まれないための基本let cookie = format!(    "library_session={}-{}-session; Path=/; HttpOnly; SameSite=Lax",    patron.username, patron.role);security header は、各 handler に散らすより response helper に寄せます。ここで入れる header の役割はこうです。X-Content-Type-Options: nosniff: ブラウザが content type を推測しない。text を script として解釈させないX-Frame-Options: DENY: 自サイトを iframe で他サイトに埋め込ませない。clickjacking（透明な iframe に被せて意図しないクリックをさせる攻撃）対策Content-Security-Policy: default-src 'self': 自ドメインの資源だけ読み込みを許可。XSS の影響範囲を狭める第二の壁Permissions-Policy: camera や geolocation などの browser API を明示的に閉じるCross-Origin-Resource-Policy: cross-origin 読み込みの扱いを明示するasync fn add_security_headers(mut response: Response) -> Response {    let headers = response.headers_mut();    headers.insert(        HeaderName::from_static("content-security-policy"),        HeaderValue::from_static(            "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",        ),    );    headers.insert(        HeaderName::from_static("x-content-type-options"),        HeaderValue::from_static("nosniff"),    );    headers.insert(        HeaderName::from_static("x-frame-options"),        HeaderValue::from_static("DENY"),    );    headers.insert(        HeaderName::from_static("permissions-policy"),        HeaderValue::from_static("camera=(), geolocation=(), microphone=()"),    );    headers.insert(        HeaderName::from_static("cross-origin-resource-policy"),        HeaderValue::from_static("same-origin"),    );    response}fixed-app では Axum の middleware::map_response を使い、すべてのレスポンスへ共通 header を差し込んでいます。layout ごとに header を足すと漏れるため、router の外側でまとめるほうが安全です。CSRF（Cross-Site Request Forgery）は、ログイン済みユーザーのブラウザから、攻撃者のサイト経由で対象サイトへ state-changing なリクエストを送らせる攻撃です。ユーザーが意図しない投稿・送金・設定変更を踏ませられます。対策は、server が発行したランダムな token を form に埋め、POST 時に session の token と一致するかを検証することです。CSRF token 不在は、単に hidden input を足せば終わりではありません。server-side session、token 生成、token 保存、POST 時の検証が必要です。今回のコードでは /account と /loans が state-changing form なので、cookie を実セッション化した後で CSRF token を入れる順序になります。誰でも使える skill として整理するCodex や Claude Code と連携するなら、agent に full scan を自由に実行させるより、ZAP の結果をコードに戻す triage を任せるほうが安全です。この考え方は Rust / Axum だけのものではありません。Rails、Django、Express、Go、Spring でも同じです。skill に閉じ込めるべき学びは、ツール固有のコマンドではありません。ZAP の finding をコード修正へ変換する型です。finding -> route -> handler -> vulnerable pattern -> fix pattern -> regressionこの型にすると、場所を選ばず使えます。入力は ZAP の JSON か要約、対象コード、許可済みの target URL です。出力は「どの finding をどの関数で直すか」と「再検証コマンド」です。Codex 側では、公式の設定リファレンスに skills.config があり、path に SKILL.md を含む skill フォルダを指定できます。developers.openai.comCodex で使うなら、たとえば ~/.codex/config.toml か project local の .codex/config.toml にこう置けます。[[skills.config]]path = "/path/to/zap-triage"enabled = trueClaude Code 側も、SKILL.md を持つ skill を /skill-name で呼び出せます。code.claude.comClaude Code で使うなら、project ごとの .claude/skills/zap-triage/SKILL.md に置きます。個人用なら ~/.claude/skills/zap-triage/SKILL.md です。どちらでも、skill の中身は同じでよいです。今回のような作業なら、skill の指示は抽象的で十分です。tools/owasp-zap/skills/zap-triage/SKILL.md に置いた skill も、この形に寄せています。# ZAP triage1. ZAP report、application root、target URL、許可範囲を確認する2. alert、risk、route、parameter、evidence を要約する3. High / Medium finding を handler や controller に対応づける4. 脆弱な実装パターンを特定する5. 最小の修正パターンを提案する6. 再検証コマンドを付ける7. 許可されていない target へ active scan を実行しない同じ LLM でも、渡す入力で役割が変わります。active scan を握らせれば「攻撃する係」、finding を handler に戻させれば「コードレビュー係」です。agent に権限を渡すとは、職種を渡すということです。 攻撃する係を雇った覚えがないのに、active scan の鍵を握らせていないか。脆弱性スキャンを任せる前に、自分が何係を雇っているかを決めておくほうが安全です。範囲を決めずに権限だけ渡すと、何が起きても「agent のせいではない」「自分のせいでもない」という真空地帯ができます。これは一番やってはいけないことです。修正は finding ごとに小さく切ります。XSS の escape、SQL の placeholder、download の allow list、header の共通化を別々に確認します。この skill は、次のように呼べば別プロジェクトでも使えます。Use zap-triage.Report: reports/zap-full.jsonApplication root: .Target URL: https://staging.example.comTask: map High and Medium findings to source code and propose minimal remediations.Do not run active scans unless I explicitly ask.この skill を他の現場で使う時に、固定しておくべき入力は 4 つです。 入力  意味  Report  ZAP の JSON、Markdown、または要約  Application root  ソースコードの root  Target URL  許可済みの scan 対象  Scope  active scan をしてよい範囲 逆に、skill に持たせないものもあります。特定プロジェクトの port、Docker service 名、修正先ファイル名、ZAP の実行許可です。これらを skill の中に書き込むと、別プロジェクトで使えません。skill は「どこをスキャンするか」を決めるものではなく、「出た finding をどう読むか」を決めるものです。SKILL.md の中身は、3 層に分けると再利用しやすくなります。Safety Boundary -> Workflow -> Output FormatSafety Boundary には、許可されていない active scan をしないことを書きます。scope を広げないこと、巨大な HTML report を最初から全部読まないこともここです。Workflow には、summary 作成、route 分類、handler への対応づけ、脆弱な実装パターンの特定を書きます。Output Format には、confirmed、needs reproduction、false positive、scan setup issue の分類を書きます。こうすると、skill は知識メモではなく作業手順になります。別の言語や framework でも、XSS は出力境界、SQL Injection は query 境界へ戻せます。Path Traversal は filesystem 境界、header は response 境界です。ここが、ZAP triage skill として一番持ち帰りやすい学びです。再検証までを修正に含める修正したら、同じ入口で再確認します。手元では次のような順番にします。ここでも成果物の形式を分けます。Rust のコマンドは code 側の品質確認です。Playwright CLI はブラウザ表示を PNG として残す確認です。ZAP は security finding の再確認です。cd tools/owasp-zap/fixed-appcargo fmt --checkcargo clippy --all-targets --all-features -- -D warningscargo test --allcd ..docker compose up --build -d fixed-app./scripts/wait-for-http.sh "http://127.0.0.1:18081/health"npx --yes playwright screenshot --wait-for-timeout=1000 "http://127.0.0.1:18081/book?id=1%20OR%201=1" reports/playwright-fixed-sqli.pngZAP_TARGET=http://fixed-app:5000 \REPORT_PREFIX=zap-full-fixed \ZAP_MAX_SCAN_MINUTES=3 \ZAP_MAX_RULE_MINUTES=1 \./scripts/run-zap.sh fullnode scripts/summarize-zap-json.mjs reports/zap-full-fixed.json > reports/zap-findings-summary-fixed.mdreports/playwright-fixed-sqli.png は、SQL Injection payload を入れた URL の画面証跡です。PNG は ZAP レポートではありません。人間が「修正後にどう見えるか」を確認するために残します。XSS なら reports/zap-findings-summary-fixed.md から Cross Site Scripting (Reflected) が消えたかを見ます。SQL Injection なら /book?id=1%20OR%201=1 が 400 になることを Playwright か curl で見ます。Path Traversal なら /download?file=../secret-config.txt が 404 になることを確認します。実際の再実行では、修正前後でこう変わりました。 finding  修正前 vulnerable-app  修正後 fixed-app  Cross Site Scripting (Reflected)  High 8 件  PASS  SQL Injection  High 3 件  PASS  Path Traversal  High 1 件  PASS  CSP header missing  Medium 9 件  PASS  X-Content-Type-Options missing  Medium 8 件  PASS  Anti-CSRF token absence  Medium 6 件  PASS 修正後にも Anti-CSRF Tokens Check は残りました。これは、サンプルの token が実セッション管理まで持っていないためです。ZAP の High finding を閉じる目的では改善を確認できました。本番相当の CSRF 対策にするなら、server-side session と token store を実装する必要があります。ZAP の finding は修正の入口です。出口は、コード上の root cause が消え、同じ再現手順で挙動が変わり、ZAP の summary から該当 finding が消えることです。生成 AI 時代に ZAP を回す理由生成 AI がコードを書く割合は増えています。agent が route、handler、SQL、HTML を量産します。これ自体は早くて便利です。ただし、agent のコードは静的解析を通っていても、出力境界の escape、SQL の placeholder、ファイル名の正規化で抜けがちです。全件を人間のレビューだけで拾いきるのは、難しくなっています。このような世界観では、OWASP ZAP のような DAST の立ち位置が上がります。baseline scan を CI の軽い gate として置きます。full scan はローカルや壊してよい staging で回します。これで「最低限のセキュリティ」を自動で担保できます。走らせるコストは小さく、finding を放置するコストは大きくなります。生成 AI 時代の DAST は、遅い人間レビューの代替ではありません。人間が見切れない量を、攻撃面のまま実行して確かめる道具です。agent にコードを書かせる環境で、DAST を通さない理由はほとんどありません。無料、OSS、スクリプト化しやすく、レポートも機械処理しやすい。「最低限」を入れておくかどうかで、後の冷や汗の量は変わります。手前味噌ですが、私の所属する組織には Securify という Web セキュリティのプラットフォームもあります。ASM、脆弱性診断、CSPM、SBOM、Web アプリ診断を 1 つにまとめた形で、ZAP のような OSS DAST とは、役割の重なる部分と違う部分があります。Securify は複数のスキャナや診断から出てくる脆弱性や設定ミスを単一のダッシュボードに集めて一元管理します。資産の重要度や外部公開状況をもとに、AI が対応優先度を自動でスコアリングするので、「どの finding から直すか」をチームで決める手間が小さくなります。専門知識がなくても運用に乗せやすく、持続的にセキュリティを回し続けるための土台として作られています。宣伝が続いて恐縮ですが、ZAP の triage skill は「1 つの scan を handler に戻す」作業を支えます。Securify のようなプラットフォームは「複数の scan と資産情報をまたいで、組織全体の優先度を決める」作業を支えます。同じセキュリティ運用でも、扱う粒度と時間軸が違います。宣伝までに置いておきます。www.securify.jpこの記事が少しでも伸びたら、ZAP の様々な使い方（認証付きの context 設定、automation framework、CI への組み込みなど）の続編を書く予定です。最初の想定から外れた話正直に告白します。この記事は最初、非エンジニアのバイブコーディング向けに書き始めました。「AI に雑なコードを書かせても、ZAP を回せば最低限のチェックになりますよ」くらいの軽い紹介のつもりでした。ところが書いているうちに熱が入ります。Rust の handler、SQL の placeholder、HTML escape の文脈、skill の抽象設計まで踏み込みました。読み返すと、最初の想定読者には全く似つかわしくない記事になっています。まあ、書き始めの狙いと書き上がりのトーンがずれるのは、いつものことです。そのまま出します。まとめZAP の記事で大事なのは、alert 名を並べることではありません。alert を route に戻し、handler に戻し、修正方針に戻すことです。今回なら、reflected XSS は search や server_error の HTML 出力境界に戻します。SQL Injection は book と login の文字列連結 SQL に戻します。Path Traversal は download の join に戻します。Medium finding は cookie と security header の共通処理に戻します。この形にすると、ZAP と agent skill の連携も自然になります。ZAP は証拠を集める。agent は証拠をコードに対応づける。人間は修正範囲を選び、再検証で閉じる。この 3 つのうち 1 つでも欠けると、残るのは「ZAP を回した履歴」だけで、直したことにはなりません。冒頭で、alert 名だけを見ても修正は始まらないと書きました。今はこう言えます。ZAP は finding を出す道具ではない。handler を書き直すための道具だ。 使い方は、alert 名を眺める作業ではなく、alert を関数名へ変換する作業です。この変換がいつも綺麗に終わるかというと、そんなことはありません。次の finding が出た時、落ち着いて同じ手順を踏めるかは、その時になってみないと分かりません。それでも、alert 名を眺める時間が以前より短くなるなら、この手順は役に立っています。たぶん。]]></content:encoded>
            <author>nwiizo</author>
        </item>
        <item>
            <title><![CDATA[Pod を再起動せずにリソースを変えたい — In-Place Update of Pod Resources まとめ]]></title>
            <link>https://sreake.com/blog/how-to-update-pod-resources-in-place-kubernetes/</link>
            <guid isPermaLink="false">https://sreake.com/blog/how-to-update-pod-resources-in-place-kubernetes/</guid>
            <pubDate>Thu, 23 Apr 2026 01:55:57 GMT</pubDate>
            <content:encoded><![CDATA[はじめに 2026年2月から3月末までの期限付きのインターンとして金古さんに参画いただきました。インターン期間中に執筆いただいた記事をお届けします。 再起動したくない、でもリソースを変えたい 今回は EKS 1.35 + […]The post Pod を再起動せずにリソースを変えたい — In-Place Update of Pod Resources まとめ first appeared on sreake.com | 株式会社スリーシェイク.]]></content:encoded>
            <author>Sreake</author>
        </item>
        <item>
            <title><![CDATA[Cortex Analystで「自然言語→SQL」分析基盤を構築する — もうSQLが書けなくても大丈夫？]]></title>
            <link>https://sreake.com/blog/construct-analytics-faundation-with-cortex-analyst/</link>
            <guid isPermaLink="false">https://sreake.com/blog/construct-analytics-faundation-with-cortex-analyst/</guid>
            <pubDate>Tue, 21 Apr 2026 23:10:42 GMT</pubDate>
            <content:encoded><![CDATA[1. はじめに 「先月の売上を部門別に見せてほしい」「ECサイト経由の注文数の推移を出して」— データチームにはこうした分析依頼が日々寄せられます。依頼のたびにSQLを書いて結果を返す、という対応を繰り返しているチームも […]The post Cortex Analystで「自然言語→SQL」分析基盤を構築する — もうSQLが書けなくても大丈夫？ first appeared on sreake.com | 株式会社スリーシェイク.]]></content:encoded>
            <author>Sreake</author>
        </item>
    </channel>
</rss>