2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIコード補完はTab一発で比べない。「近傍→別関数→別ファイル」の編集経路を測る

2
Posted at

type FitMode = "crop" | "contain"が一発で出ても、自分ならその補完を成功とは数えない。

型だけ直って、previewのobject-fitcoverのままなら画面は変わらない。presetとtestが古いままなら、次の人がその不整合を拾うことになる。一行の候補は正しくても、変更全体としては途中だ。

VS CodeチームはGitHub Copilotのinline suggestionsについて、completion、近距離のnext edit、長距離のeditを一つの3-in-1 modelで扱う設計を紹介している。ここで気になるのはmodel名よりも、ひとつの変更意図がどこまで切れずに運ばれるかだ。

そこで、React + TypeScriptの小さなInstagram previewをfixtureにする。比較するのは候補の見栄えではない。最初の編集からtypecheckとtestが通るまでの「編集経路」だ。

なお、この記事で使うlocal / nearby / cross-fileEditPathRunは、評価用にこちらで決めるラベルとschemaである。GitHub CopilotやVS Codeの公式telemetryではない。

評価対象を3段階の距離に分ける

今回の変更意図は、これだけに固定する。

Instagram previewにcontain modeを追加する。
型、preset、preview、testの整合性を保つ。

編集経路は次のように分ける。

level 観測する場所 今回の例
local 現在行とカーソル近傍 FitModecontainを追加
nearby 同じfeature内の別関数 objectFitの計算と表示labelを更新
cross-file 別fileの関連実装 preset dataとtestを更新

距離が長ければ優秀、という話ではない。関係のないfileまで変えたら、むしろreview負荷は増える。見たいのは、必要な場所へ変更意図が届き、余計な場所へ広がっていないかだ。

同じ初期状態へ戻せるfixtureを作る

fixtureは小さくする。実プロダクト全体で試すと、補完の差よりrepositoryの偶然を測りやすい。

src/features/instagram/
  presets.ts
  types.ts
  instagram-preview.tsx
  instagram-preview.test.tsx
fixtures/instagram/
  square.png
  portrait.png
  story.png
artifacts/inline-edit-path/
  runs.jsonl

初期状態ではcropだけを持たせる。

// src/features/instagram/types.ts
export type FitMode = "crop"

export type InstagramPreset = {
  id: "square" | "portrait" | "story"
  width: number
  height: number
  fitMode: FitMode
}

preview側には変換関数を置く。

// src/features/instagram/instagram-preview.tsx
import type { CSSProperties } from "react"
import type { FitMode, InstagramPreset } from "./types"

export function toObjectFit(mode: FitMode): CSSProperties["objectFit"] {
  return "cover"
}

export function InstagramPreview(props: {
  src: string
  preset: InstagramPreset
}) {
  return (
    <img
      src={props.src}
      alt=""
      width={props.preset.width}
      height={props.preset.height}
      style={{ objectFit: toObjectFit(props.preset.fitMode) }}
    />
  )
}

この時点ではFitModeも表示もcrop前提だ。開始位置はtypes.tsFitModeに固定し、preview側を先回りして直しておかない。

比較用の作業場所も分けたい。普段のworktreeでreset --hardを繰り返すのは怖いので、自分なら使い捨てworktreeを作る。

git worktree add ../inline-edit-path-fixture \
  -b experiment/inline-edit-path HEAD
cd ../inline-edit-path-fixture

# fixtureを追加して初期状態を保存する
git add src/features/instagram fixtures/instagram
git commit -m "fixture: baseline before contain mode"
BASELINE=$(git rev-parse HEAD)

runをやり直すときだけ、この使い捨てworktree内で初期commitへ戻す。

git reset --hard "$BASELINE"

git reset --hardは未commitの変更を消す。普段の作業directoryでは実行しない。

run metadataには、少なくとも次を残す。

  • repositoryの初期commit
  • editor version
  • extension version
  • 開始fileとcaret位置
  • 固定した変更意図

modelの内部IDや非公開event名は推測して埋めない。

入力画像を先に固定する

画像の縦横比がrunごとに変わると、補完の評価に別の問題が混ざる。今回は次の3枚を固定する。

fixture size ratio
square.png 1080 × 1080 1:1
portrait.png 1080 × 1350 4:5
story.png 1080 × 1920 9:16

同じ元画像からfixtureを用意するなら、ブラウザ内でpresetを選べるResize Image for Instagramで寸法を揃えておくと、画像準備の手順を固定しやすい。ここで測りたいのはresize作業ではないので、3枚を作ったらfixtureとしてcommitし、run中は触らない。

local: 現在行が直っても完了にしない

開始点はFitModeだ。

export type FitMode = "crop" | "contain"

候補を採用したら、まずlocal到達として記録する。ただし、この時点ではgateをpassにしない。現在行が正しいことと、変更全体が成立することは別だからだ。

partial acceptanceを使った場合も残す。候補の半分を人が直したのに、採用回数だけを見るとAIが全部書いたように見えてしまう。

nearby: 別関数へ意味が届いたかを見る

次は同じfeature内のtoObjectFit()を見る。

export function toObjectFit(mode: FitMode): CSSProperties["objectFit"] {
  return mode === "contain" ? "contain" : "cover"
}

ここでは候補が出たかだけでなく、内容を読む。

  • containcropを逆にしていないか
  • labelや分岐が古い値を参照していないか
  • 無関係なrenameやformattingを広げていないか
  • 候補が出なかったのか、表示した候補をrejectしたのか

最後の2つは分けておく。nonerejectedを同じ失敗へ丸めると、次回の比較で何が変わったのか分からない。

cross-file: presetとtestまで追う

期待する変更pathを先に決めておく。

[
  "src/features/instagram/types.ts",
  "src/features/instagram/presets.ts",
  "src/features/instagram/instagram-preview.tsx",
  "src/features/instagram/instagram-preview.test.tsx"
]

presets.tsでは、たとえばStoryだけcontainへ切り替える。

// src/features/instagram/presets.ts
import type { InstagramPreset } from "./types"

export const presets: InstagramPreset[] = [
  { id: "square", width: 1080, height: 1080, fitMode: "crop" },
  { id: "portrait", width: 1080, height: 1350, fitMode: "crop" },
  { id: "story", width: 1080, height: 1920, fitMode: "contain" },
]

testは型が通ることではなく、previewの振る舞いを見る。

// src/features/instagram/instagram-preview.test.tsx
import { describe, expect, it } from "vitest"
import { toObjectFit } from "./instagram-preview"

describe("toObjectFit", () => {
  it("contain modeでは画像全体をframe内へ収める", () => {
    expect(toObjectFit("contain")).toBe("contain")
  })

  it("crop modeではframeを埋める", () => {
    expect(toObjectFit("crop")).toBe("cover")
  })
})

AIがtest fileへ移動しても、期待値を実装の誤りに合わせて書き換えたら意味がない。Storyは9:16portraitは4:5というfixture側の前提と一緒にdiffを読む。

suggestion全文ではなく、最小の記録を残す

promptやproprietary codeを丸ごと収集する必要はない。比較に使う項目だけでよい。

type EditLevel = "local" | "nearby" | "cross-file"
type SuggestionState = "none" | "shown"
type Gate = "not-run" | "pass" | `fail:${string}`

type EditPathRun = {
  runId: string
  repositoryCommit: string
  editorVersion: string
  extensionVersion: string
  level: EditLevel
  targetPath: string
  suggestion: SuggestionState
  accepted: boolean
  partialAcceptance: boolean
  manualRepair: boolean
  touchedPaths: string[]
  gate: Gate
}

JSONLなら、途中でeditorを閉じてもそこまでの観測が残る。次はserialization形式の例であり、実測結果ではない。

{"runId":"<run-id>","repositoryCommit":"<sha>","editorVersion":"<version>","extensionVersion":"<version>","level":"local","targetPath":"src/features/instagram/types.ts","suggestion":"shown","accepted":false,"partialAcceptance":false,"manualRepair":false,"touchedPaths":[],"gate":"not-run"}

manualRepair: trueは、即failureという意味ではない。「AIの提案だけでは完了しなかった」という事実を残す欄だ。そこを隠さなければ、補完が役立ったrunと、人間が回収したrunをあとで分けられる。

Git差分とgateで完了を判定する

編集が終わったら、変更pathを取る。

git diff --name-only "$BASELINE"

期待外のpathが出たら、cross-file能力の加点にはしない。変更理由をreviewする。期待pathが足りない場合も、そのまま記録する。

次にtypecheckとbehavior testを走らせる。

pnpm tsc --noEmit
pnpm vitest run src/features/instagram

これはVite + Vitest構成の例なので、実際にはrepositoryのscriptへ合わせる。typecheckが通っても表示の意味は保証されない。testが通っても、期待値だけを都合よく変更していないかはdiffで確認する。

run途中ならnot-runのまま残す。空欄をpassに変えない。

失敗fixtureを先に決める

成功例だけでは、評価器そのものが甘くなる。最低限、次のfailureを区別できるようにする。

fixture 状態 記録したいこと
local-only 現在行だけ変更 cross-fileへ未到達
broad-but-wrong 複数fileを変更 aspect ratio誤りでtest fail
green-typecheck-red-test 型は通る behavior testはfail
manual-repair 候補採用後に人が修正 AIだけの完了には数えない

broad-but-wrongでは、Storyの9:16とportraitの4:5を混同するcaseが使いやすい。複数fileへ進んだという見た目は派手だが、意味を壊していれば成功ではない。

一度のrunで4種類を全部再現する必要はない。それぞれ独立したfixtureにしたほうが、どの判定が壊れたか読みやすい。

結果表はgateを右端に置く

最初は空のtemplateでよい。

run local nearby cross-file unexpected paths manual repair typecheck test
001 accepted / rejected / none accepted / rejected / none accepted / rejected / none none / paths yes / no pass / fail / not-run pass / fail / not-run

upgrade前後を比べる場合も、初期commit、editor設定、入力画像、開始位置、gateを固定する。数回のrunを製品benchmarkや統計的な精度と呼ぶのは無理がある。それでも、自分のrepositoryで「どこから手修正が増えたか」を見る回帰fixtureとしては使える。

acceptance rateだけでは、関連fileを人間が直した時間が消える。逆に、cross-fileの候補数だけでは不要な変更の広がりを見落とす。

見るべき場所は、最初のTabではなく、変更意図が切れた地点だ。localで止まったのか、別関数で意味を取り違えたのか、testまで届いたが期待値を壊したのか。そこまで記録すれば、補完modelを更新したときも「なんとなく良くなった」以外の話ができる。

参考資料

2
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?