これは技術編です。何を確かめたくて、結果をどう受け止めたかを読みやすくまとめた読みもの版は「人間に読みやすい設計は、AI にも変更しやすいのか ── 3 つの構成で 54 回試した」にあります。
ai-readable-code-lab は、人間に読みやすい設計が、AI にとっても変更しやすいのか を同じ条件で比べるための実験リポジトリです。この記事では、Phase 1(設計構造の比較)を、リポジトリの main(HEAD は 342e50c)と、各実験ブランチに保存した評価の生データから順に追います。
表の数字は、54 回分の metadata.json と evaluation.json を読み直して集計し直したものです。リポジトリの集計 JSON とは一致しました。記録どうしの食い違いもいくつかあったので、後半にまとめます。
問いと、実験前に置いた仮説
計画書 docs/PROJECT_PLAN.md で置いた問いは 1 つです。
同じ機能を持つコードでも、ファイル構成や責務の分け方によって、AI のコード変更精度は変化するのか。
仮説は実験前に 5 つ書いていました。A:局所的な変更では 1 ファイル構成が有利。B:横断的な変更ではレイヤー分割が有利。C:分けすぎると見落としで実装漏れが増える。D:機能単位構成が総合的に最も安定する。E:大事なのはファイル数ではなく、必要な情報を低コストで見つけられること。先に答えを言うと、54 回のデータでは どの仮説も支持も棄却もできませんでした。理由は、この記事の後半に書きます。
題材:タスク管理 API
3 構成とも、Node 22・TypeScript・Fastify・Vitest で、データはメモリ上の Map です。外部 DB・認証・ネットワークは使いません。課題 02〜06 の出発点(v1.0.1)の API は次のとおりです。
| Method | Path | 成功 | 失敗 |
|---|---|---|---|
| POST | /tasks |
201 | 空タイトルは 400 |
| GET | /tasks |
200(ARCHIVED を除外) | - |
| GET | /tasks/:id |
200 | 未存在は 404 |
| PATCH | /tasks/:id |
200 | 空タイトル 400、未存在 404、存在しない担当者 404 |
| POST | /tasks/:id/complete |
200 | 二重完了 409、未存在 404 |
| DELETE | /tasks/:id |
204 | 未存在 404 |
担当者は user-1 と user-2 だけが存在します。期限が過去なら、レスポンスの warnings に 'due date is in the past' が入ります(保存は拒否しません)。
3 構成が同じ動きをすることは、phase1-architecture/contracts/task-api-contract.ts の 共有契約テスト で確かめています。1 つのテスト関数を、3 構成の buildApp にそれぞれ渡して実行する作りです。
3 つの構成
課題 02〜06 の出発点にした Baseline v1.0.1(4ae1304)のファイルと行数です。
monolithic
phase1-architecture/monolithic/
├── src/app.ts 84 行(型・保存・判定・ルートをすべて)
└── tests/app.test.ts 21 行buildApp() の中に、Map、連番、時刻、期限切れの判定、レスポンスの組み立て、全ルートが入っています。
layered
phase1-architecture/layered/
├── src/
│ ├── domain.ts 15 行(Task 型と isOverdue)
│ ├── repository.ts 21 行(Map と連番)
│ ├── service.ts 73 行(業務ルール。失敗は Error を throw)
│ └── app.ts 61 行(HTTP。Error を status に変換)
└── tests/app.test.ts 14 行計画書では presentation/controllers や application/usecases まで分けた深い構成を描いていましたが、実際に作ったのは 4 ファイルの平らな分割です。
この構成の特徴は、service が投げた Error を、app 側で メッセージの文字列 を見て HTTP のステータスに振り分けていることです。
// phase1-architecture/layered/src/app.ts(v1.0.1)
} catch (e) {
const message = (e as Error).message;
return reply.code(message === 'title is required' ? 400 : 404).send({ error: message });
}新しい失敗を足すときは、service の throw と app の振り分けを、同じ文字列で揃える必要があります。課題 02 では、layered の 3 回とも service.ts と app.ts の両方を直していました。
feature-based
phase1-architecture/feature-based/
├── src/
│ ├── app.ts 7 行(registerTaskRoutes を呼ぶだけ)
│ └── tasks/
│ ├── task.ts 20 行(Task 型と asResponse)
│ ├── repository.ts 19 行
│ └── routes.ts 72 行(全ルートと業務ルール)
└── tests/app.test.ts 14 行業務ルールは routes.ts の各ハンドラーに直接書いてあります。計画書の tasks/create/、tasks/update/ のような操作ごとのフォルダまでは分けていません。
3 構成とも、とても小さい ことは先に強調しておきます。いちばん大きい monolithic でも本体は 84 行です。
6 つの課題
課題文は tasks/phase1/task-0X-*/prompt.md に、仕様と期待動作は specification.md と expected-behavior.md にあります。
| 課題 | 内容 | Baseline |
|---|---|---|
| 01 | category を作成・更新・取得で扱う |
v1.0.0 |
| 02 | 完了済みタスクの担当者変更を 409 で拒否する | v1.0.1 |
| 03 | 操作履歴を記録し GET /tasks/:id/history を足す |
v1.0.1 |
| 04 | 完了済みタスクが期限切れになるバグを直す | v1.0.1 |
| 05 | DELETE を、200 と ARCHIVED を返すアーカイブに変える |
v1.0.1 |
| 06 | POST /tasks/:id/duplicate で複製する(指示は少なめ) |
v1.0.1 |
課題 01 だけは、履歴とアーカイブの実装が入った v1.0.0 から始めています。課題 02〜06 のために、それらを取り除いた v1.0.1 を別に固定しました。
課題文には、構成ごとの特別な指示を入れていません。どの課題文も、要件のあとに「既存コードと公開テストを確認し、…テストを追加して、lint・型検査・テストを実行してください」という定型の一文が続きます。
ハーネス
実行順はシードで固定する
54 回の順番は、scripts/generate-main-run-order.mjs がシード 20260716 の線形合同法と Fisher–Yates で混ぜています。
// scripts/generate-main-run-order.mjs
const seed = 20260716;
let state = seed;
const random = () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
for (let index = conditions.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[conditions[index], conditions[swapIndex]] = [conditions[swapIndex], conditions[index]];
}評価の時刻を並べると、計画の順番から入れ替わったのは 16〜18 番目と 44〜47 番目の 2 か所だけでした。
Runner と Evaluator を分ける
手順は docs/MAIN_EXPERIMENT_EXECUTION.md に書いています。
- Runner:固定タグから専用の clone を作り、新しいセッションで、対象の構成・課題文・公開テストだけを渡す。
hidden-tests/、results/、ほかの構成、Dry Run のブランチは渡さない - Runner が実装・テスト・
npm run validate・コミットまでを行う。人間や親のエージェントはコードを直さない - Evaluator:別の clone・別のセッションで Runner のコミットを checkout し、隠しテストを入れて検証する
- Evaluator は Runner のソースを直さない。直した場合は
humanCodeCorrections: trueを記録し、通常の集計から外す
作業報告書によると、Runner も Evaluator も Codex CLI のセッションです。metadata.json には 54 回すべて "model": "gpt-5.6-terra"、"reasoningLevel": "medium" と記録されています。評価の時刻は 2026-07-17 11:31 から 2026-07-20 00:07 です。
隠しテストは Git に入れない
隠しテストは hidden-tests/ に置き、.gitignore で除外し、vitest.config.ts の exclude でも通常のテスト実行から外しています。Evaluator だけが scripts/run-hidden-tests を使います。課題と構成は許可リストで縛り、任意のパスは受け取りません。
# scripts/run-hidden-tests(抜粋)
case "$target" in
monolithic|layered|feature-based) ;;
*)
echo "unsupported target: $target" >&2
exit 64
;;
esac
test_file="hidden-tests/phase1/${task_id}.test.ts"
HIDDEN_TEST_TARGET="$target" npx vitest run --config hidden-tests/vitest.config.ts "$test_file" > "$result_file" 2>&1保存された結果を見ると、隠しテストは 課題ごとに 1 つのテストケース です(hiddenTests.total は 54 回すべて 1)。1 つのケースの中で複数のことを確かめていますが、通過か失敗かの 2 値になります。テスト本体はまだ公開していません。
1 回ごとに残すもの
変更量は scripts/collect-git-metrics が git diff "$baseline"...HEAD の --name-only・--stat・--binary で取ります。各実験ブランチ experiment/p1/<構成>/<課題>/r0N の results/raw/<実験 ID>/ に、metadata.json、evaluation.json、changes.patch、git-stat.txt、changed-files.txt、公開テストと隠しテストの出力を残しています。評価 JSON の中心はこの部分です。
"automatedChecks": {
"build": null, "typecheck": false, "format": null, "lint": null,
"publicTests": { "passed": 5, "total": 9 },
"hiddenTests": { "passed": 0, "total": 1 }
}npm run validate は typecheck → format:check → lint → test → build の順に && でつないでいるので、途中で落ちると後ろは実行されず null になります。
指標
- 完全検証通過:build・typecheck・format・lint がすべて
trueで、公開テストと隠しテストが全件通過 - 隠しテスト通過:隠しテスト 1/1
- 公開テスト全通過:
publicTests.passed === total - 変更ファイル数・追加行・削除行:Evaluator が記録した
metricsの値
評価基準 docs/EVALUATION_RULES.md には 100 点満点の採点表(要件 25、隠しテスト 20、既存テスト維持 15、責務配置 10 など)がありますが、54 回すべて scores は null です。読んだファイル数、コマンド数、修正ループ数、トークン数も、54 回すべて null でした。計画にあった「探索コスト」は、今回は測れていません。
結果
構成別
| 構成 | 完全検証 | 隠しテスト | 公開テスト全通過 | 平均変更ファイル | 平均追加行 | 平均削除行 |
|---|---|---|---|---|---|---|
| monolithic | 15/18(83.3%) | 18/18 | 15/18 | 2.28 | 62.8 | 2.61 |
| feature-based | 13/18(72.2%) | 16/18 | 14/18 | 3.11 | 61.7 | 2.11 |
| layered | 12/18(66.7%) | 18/18 | 12/18 | 4.06 | 68.0 | 2.78 |
| 全体 | 40/54(74.1%) | 52/54 | 41/54 | 3.15 | 64.2 | 2.50 |
課題 05 を除いた 45 回では、こうなります。
| 構成 | 完全検証 | 隠しテスト | 平均変更ファイル |
|---|---|---|---|
| monolithic | 15/15(100%) | 15/15 | 2.33 |
| feature-based | 13/15(86.7%) | 14/15 | 2.93 |
| layered | 12/15(80.0%) | 15/15 | 4.00 |
この差がどれくらい確かなのかを見るために、Fisher の正確確率検定(両側)と、Wilson の 95% 信頼区間を計算しました。
| 比較 | p 値 |
|---|---|
| monolithic 15/18 と layered 12/18 | 0.44 |
| 課題 05 を除く monolithic 15/15 と layered 12/15 | 0.22 |
| 隠しテスト feature-based 16/18 と monolithic(または layered)18/18 | 0.49 |
完全検証率の 95% 信頼区間は、monolithic 61〜94%、feature-based 49〜88%、layered 44〜84% です。区間はほとんど重なっていて、構成のあいだに差があるとは言えません。並び順は記録として残しますが、順位として読むのは無理があります。
課題別
| 課題 | 完全検証 | 隠しテスト | 公開テスト全通過 | 平均変更ファイル | 平均追加行 |
|---|---|---|---|---|---|
| 01 category | 8/9 | 9/9 | 8/9 | 3.00 | 41.0 |
| 02 担当者 | 9/9 | 9/9 | 9/9 | 2.78 | 61.1 |
| 03 履歴 | 9/9 | 9/9 | 9/9 | 3.67 | 80.8 |
| 04 期限切れ | 8/9 | 9/9 | 8/9 | 2.44 | 47.3 |
| 05 アーカイブ | 0/9 | 8/9 | 1/9 | 3.44 | 46.6 |
| 06 複製 | 6/9 | 8/9 | 6/9 | 3.56 | 108.2 |
仮説 B で「レイヤー分割が有利」と予想していた横断的な課題 03(履歴)は、3 構成とも 3/3 でした。差が出る難しさになっていなかった、というのが正直な読みです。
構成 × 課題
完全検証の通過数と、変更ファイル数(3 回分)です。
| 課題 | monolithic | layered | feature-based |
|---|---|---|---|
| 01 | 3/3 [2, 2, 2] | 2/3 [4, 4, 4] | 3/3 [3, 3, 3] |
| 02 | 3/3 [4, 2, 2] | 3/3 [3, 3, 5] | 3/3 [2, 2, 2] |
| 03 | 3/3 [2, 2, 2] | 3/3 [5, 5, 5] | 3/3 [4, 4, 4] |
| 04 | 3/3 [2, 2, 2] | 2/3 [3, 3, 3] | 3/3 [3, 2, 2] |
| 05 | 0/3 [2, 2, 2] | 0/3 [5, 4, 4] | 0/3 [5, 3, 4] |
| 06 | 3/3 [2, 5, 2] | 2/3 [4, 5, 4] | 1/3 [4, 3, 3] |
変更ファイル数の中身
changes.patch を git apply --numstat で数え直し、src/・tests/・docs/ に分けました。
| 構成 | 本体ファイル | テストファイル | 本体の追加行 | テストの追加行 | docs を触った回 |
|---|---|---|---|---|---|
| monolithic | 1.00 | 1.06 | 12.9 | 42.1 | 2 |
| layered | 2.83 | 1.11 | 20.6 | 43.7 | 1 |
| feature-based | 1.94 | 1.06 | 14.7 | 45.6 | 2 |
わかったことは 2 つあります。
1 つ目は、平均変更ファイル数には docs/TODO.md や作業報告書が混ざっている ことです。54 回中 5 回で、Runner が docs/TODO.md の更新や docs/reports/ の作業報告書まで書いていました。リポジトリにはそういう文書が並んでいるので、それにならったのだと思いますが、ログがないので理由はわかりません。本体とテストだけで数えると monolithic 2.06、feature-based 3.00、layered 3.94 です。順番は変わりません。
2 つ目は、追加行の大半がテスト だということです。本体の追加は平均 13〜21 行で、テストは 42〜46 行でした。「課題 06 は平均 108 行が必要だった」のではなく、108 行の多くはテストと docs です。
失敗の分析
完全検証を通らなかった 14 回を、評価 JSON の記録から並べます。
| 実験 ID | 落ちた段階 | 隠しテスト | 記録された分類 |
|---|---|---|---|
| LAYERED-T01-R01 | format | 1/1 | formatting |
| LAYERED-T04-R02 | format | 1/1 | formatting failure |
| LAYERED-T06-R02 | format | 1/1 | formatting failure |
| FEATURE-BASED-T06-R03 | format | 1/1 | formatting |
| FEATURE-BASED-T06-R01 | typecheck | 0/1 | typecheck failure, functional failure |
| 課題 05 の 8 回(3 構成すべて) | 公開テスト | 1/1 | shared contract mismatch 6、implementation omission 2 |
| FEATURE-BASED-T05-R01 | 隠しテスト | 0/1 | implementation omission |
整形だけで落ちた 4 回
4 回とも隠しテストは通っていて、Prettier の --check だけで落ちています。うち 3 回が layered で、整形されていなかったのは layered/tests/app.test.ts や layered/src/domain.ts でした。課題文は「lint・型検査・テスト」の実行を求めていて、整形は名指ししていません。
課題 05:評価の側の矛盾
共有契約テストは、Baseline のまま DELETE に 204 を期待しています。
// phase1-architecture/contracts/task-api-contract.ts(v1.0.1)
expect((await app.inject({ method: 'DELETE', url: `/tasks/${task.id}` })).statusCode).toBe(
204,
);一方、課題 05 の仕様は「レスポンスは 200 で status: ARCHIVED を返す」です。課題に従えば公開テストで落ち、公開テストに合わせれば隠しテストで落ちます。どちらを選んでも完全検証を通れない 課題でした。54 回の中で contracts/ を変更した Run は 1 つもありません。
9 回のうち 8 回は課題に従って 200 を返し、公開テストの 1 件だけで落ちています。残る 1 回(FEATURE-BASED-T05-R01)は、状態を ARCHIVED にしながら 204 のまま返していて、公開テストは通り、隠しテストで落ちました。
// FEATURE-BASED-T05-R01: phase1-architecture/feature-based/src/tasks/routes.ts
- repository.remove(task.id);
+ task.status = 'ARCHIVED';
+ task.updatedAt = now();
return reply.code(204).send();Dry Run でこの矛盾に気づけなかった理由も、履歴に残っていました。課題 05 の Dry Run では、実装した側が共有契約を expect([200, 204]).toContain(...) に緩めていたのです。本番の Baseline には、その変更が入っていませんでした。
FEATURE-BASED-T06-R01:同じルートを 2 回書いた
54 回の中で、要件まで壊れたのはこの 1 回だけです(課題 05 を除く)。Runner は 1 回の変更の中で、category を 2 回足し、複製ルートも 2 回登録していました。
// FEATURE-BASED-T06-R01: phase1-architecture/feature-based/src/tasks/routes.ts
dueDate: r.body.dueDate,
+ category: r.body.category,
+ category: r.body.category,
...
+ app.post<{ Params: { id: string } }>('/tasks/:id/duplicate', async (r, reply) => {
+ const source = repository.find(r.params.id);
+ if (!source) return reply.code(404).send({ error: 'task not found' });
+ return reply.code(201).send(asResponse(repository.duplicate(source, now())));
+ });Evaluator が保存したエラーはこうです。
error TS2300: Duplicate identifier 'category'.
FastifyError: Method 'POST' already declared for route '/tasks/:id/duplicate'v1.0.1 の Baseline には、category も複製ルートもありません。どちらも、この Run の中で自分で 2 回書いたものです。1 つ目のルートは repository.duplicate() を呼び、2 つ目はハンドラーの中で直接 repository.create() を呼んでいて、途中で方針を変えたあとに前の版を消し忘れたように見えます(Runner のログは残していないので、ここは推測です)。
隠しテストを通っても、仕様どおりとは限らない
記事を書くために差分を読み直して、隠しテストにない 2 つのケースを、課題 02 と課題 04 の 18 回分のコミットに対して手元で流しました。リポジトリの評価には含まれていない、後からの確認です。
課題 04。 仕様には「warning の既存仕様は変更しない」とあります。過去期限で完了したタスクの warnings を見ると、9 回中 7 回 で警告が消えていました。
// MONOLITHIC-T04-R01: phase1-architecture/monolithic/src/app.ts
+ const isOverdue = (task: Task) =>
+ task.status !== 'COMPLETED' && Boolean(task.dueDate && task.dueDate < now().slice(0, 10));
...
+ warnings: overdue ? ['due date is in the past'] : [],期限切れの判定を直すついでに、警告も同じ判定に寄せています。重複を減らすリファクタリングとしては自然ですが、仕様には反します。警告を残したのは LAYERED-T04-R02 と FEATURE-BASED-T04-R03 の 2 回だけです。消した 7 回は 3 構成すべてにまたがっていて(monolithic 3、layered 2、feature-based 2)、構成による偏りは見えませんでした。
課題 02。 仕様には「タイトルなど、担当者以外の更新を禁止しない」とあります。MONOLITHIC-T02-R02 は、担当者がいる完了済みタスクにタイトルだけを PATCH すると 409 を返しました。
// MONOLITHIC-T02-R02: phase1-architecture/monolithic/src/app.ts
+ if (task.status === 'COMPLETED' && request.body.assigneeId !== task.assigneeId)
+ return reply.code(409).send({ error: 'cannot change assignee of a completed task' });body に assigneeId がないと undefined !== 'user-1' になって拒否されます。担当者のいないタスクでは 200 になるので、隠しテストはこの経路を通っていなかったのだと思います。
ついでに、完了済みタスクに 同じ担当者 を入れ直したときの動きも見ると、9 回中 6 回が 409、3 回が 200 でした。仕様がこのケースを決めていないので、どちらも間違いではありません。曖昧なところは、回ごとに解釈が割れます。
隠しテストの通過率 52/54 は「隠しテストに書いた範囲では要件を満たした」という意味で、それ以上ではありません。
記録の食い違い
集計し直していて、記録どうしの食い違いも見つけました。どれも結論の向きは変えませんが、書いておきます。
- LAYERED-T05-R01 に
humanCodeCorrections: trueが付いている。 メモは「結果コミットの前に、親による整形の修正が必要だった」です。手順では集計から外すはずの Run が、54 回に含まれています。外すと layered は 12/17 で、この修正がなければ整形失敗がもう 1 回増えていた可能性があります。 - 課題 05 の「実装漏れ」3 回のうち 2 回は、根拠欄が契約の不一致そのもの。 MONOLITHIC-T05-R01 と LAYERED-T05-R02 は隠しテストを通っています。実装が足りなかったのは FEATURE-BASED-T05-R01 の 1 回だけで、内訳は「契約の矛盾 8 回、実装漏れ 1 回」と読むのが正確です。
- FEATURE-BASED-T03-R03 の
metrics。 評価 JSON は 4 ファイル・追加 29 行、git-stat.txtは 6 ファイル・追加 136 行です。集計の平均は評価 JSON の値を使っています。 - 整形で落ちた Run の公開テスト件数。 LAYERED-T04-R02 は記録上 12/13 ですが、根拠欄には「別に流すと 13/13」とあります。公開テスト全通過率には、整形の失敗が混ざっています。
リポジトリには Qiita 向けの記事の下書きもありますが、まだ公開していません。この記事は、下書きではなく生データに合わせて書いています。
妥当性への脅威
- 回数。 1 条件 3 回、1 構成 18 回です。上の検定のとおり、構成差は偶然の範囲に収まっています。
- 規模。 本体は 84〜170 行です。ファイルを探す、依存をたどる、という難しさがほとんど生まれない大きさで、仮説 C や E を試せる条件になっていません。
- モデル。 gpt-5.6-terra・
medium・Codex CLI の 1 条件だけです。 - 作り手が 1 人。 構成、課題、隠しテスト、評価を、すべて私が作りました。課題 05 の矛盾や、隠しテストの薄さはその表れです。
- 隠しテストの粒度。 課題ごとに 1 ケースで、課題 04 の警告や課題 02 のタイトル更新のような約束は確かめていません。
- 課題の偏り。 課題 02・03 は 9/9 で、天井に当たっています。課題 05 は評価の矛盾で 0/9 です。6 課題のうち、構成差を見られる課題は実質 4 つでした。
- 評価者も AI。 Evaluator は別の clone・別のセッションですが、Codex CLI のセッションです。設計の質を人が見る採点はしていません。
- Runner のログがない。 何を読み、何を実行したかは残っていないので、失敗の原因は差分からの推測になります。
再現方法
集計は、実験ブランチを取得してから 1 コマンドで作り直せます。
git clone https://github.com/naki0227/ai-readable-code-lab
cd ai-readable-code-lab
git fetch origin 'refs/heads/experiment/p1/*:refs/remotes/origin/experiment/p1/*'
npm ci
npm run summarize:main-experimentresults/summaries/main-experiment-summary.json と main-experiment-runs.csv が、54 回分の評価 JSON から作り直されます。1 回分の生データは、git show origin/experiment/p1/<構成>/<課題>/r0N:results/raw/<実験 ID>/evaluation.json のように、ブランチから直接取り出せます。
隠しテストの本体はまだ公開していないので、Evaluator の手順はいまのところ手元でしか回せません。
わかったこと、わからなかったこと
わかったことは、この大きさのコードでは、どの構成でも AI は要件をほぼ実装できた ということです。52/54 が隠しテストを通り、失敗の多くは整形と、私が作った評価の矛盾でした。
わからなかったのは、最初の問いそのものです。構成の差は偶然の範囲に収まっていて、コードが小さすぎて探索の難しさも生まれていません。
それでも、次に何を直せばいいかははっきりしました。リポジトリの TODO には、課題 05 のような契約の矛盾を実行前に見つける仕組み、人によるブラインド評価、より大きな変更課題を入れています。この記事を書きながら、隠しテストに「変えてはいけないもの」を足すこと、Runner の操作ログとトークン数を残すこと、探索が必要になる大きさの Baseline を用意することも、足したいと考えるようになりました。
構成を固定して言語を変える第 2 弾(Python・TypeScript・Go・Rust)は、計画書にはありますが、まだ始めていません。その前に、Phase 1 の測り方を直すところからやります。