跳到主要內容

【2026 最新】Claude 語言漂移怎麼量?用 PR Corpus 建立 Style Drift CI

最後更新: ·
Claude 語言漂移 PR Style Drift CI 教學首圖

Claude 語言漂移真正值得量的,不是某個「AI 味」單字出現幾次,而是團隊 PR 的詞彙分布是否離開自己的 baseline,以及這個變化有沒有讓 reviewer 更難回答「改了什麼、風險在哪、下一步是什麼」。把 quietlyload-bearing 列成禁字,最多只能改變表面;它既不能證明誰寫了 PR,也不能直接證明文字變難懂。

這個題目在 2026 年 8 月底突然升溫:Load-Bearing Vocabulary 的 Hacker News 討論截至 2026 年 8 月 31 日早上已超過 690 points。不過熱度只證明大家在意,不等於方法已經能做作者鑑定。

這篇專為想替 AI-assisted 開發建立量測、但不熟統計檢定的團隊寫:我們會把公開專案改造成一套 repo 級實驗,匯出自己的 PR corpus、保留人工/AI-assisted/bot/unknown 的不確定標記、凍結一份可版本化 baseline receipt、每週算 Jensen–Shannon distance,最後用盲化理解題把結果標成 harm_detectedno_detected_harminconclusive。完成後得到的是只提醒、不封鎖的 Style Drift CI,不是 Claude 偵測器。

先說結論:Claude 語言漂移要通過兩道驗收

Style Drift CI = 詞彙分布偏移 × 人工理解驗收;不是 Claude 作者偵測器。
分布偏移只負責告訴你「這週和以前不同」;理解驗收才回答「這個不同是否妨礙審查」。

第一道是分布門:同一個 repo、同一套斷詞規則下,比較本週 PR 與介入前八個完整週的詞彙分布。第二道是理解門:抽樣 A/B 兩版 PR 說明,讓不知道版本來源的 reviewer 回答三題。理解出現傷害時,不論分布有沒有警示都值得追查;兩道門同時亮起,只是讓「近期 style 介入」成為更具體的候選原因。

若分數升高,先檢查新產品詞、PR template、團隊成員或 release 類型是否改變。小樣本看不出差異時只能記 inconclusive;只有預先定義可接受差距,而且不確定區間完整落在等效界線內,才可記 no_detected_harm。不要為了壓低一個數字而懲罰清楚的用語。

PR Style Drift CI 從 PR corpus、詞彙分布到人工理解驗收的流程圖
Style Drift CI 的輸出是 advisory summary;詞彙分數與人工理解結果必須分開保存。

Load-Bearing Vocabulary 原專案到底量了什麼?

原始 Load-Bearing Vocabulary 專案分析公開 GitHub PR 說明,但搜尋條件要求 body 至少含十個常見英文 function words 之一,因此是偏英文的選樣,不是全部 GitHub PR。其 2026 年 8 月 30 日 analysis.js納入 595 個取樣日、85 個完整週,共 461,121 篇 PR 說明、51,079,244 次詞彙出現,經門檻後保留 19,798 個詞。在該抽樣與聚類規格下,主群由最初八週平均 0.702% 上升到截至 2026 年 8 月 17 日最近四週約 39.506%。

這不是把全部 GitHub PR 都抓下來。專案每天從十個 2.4 小時區塊各抽一個五分鐘窗口,每個窗口只取搜尋 API 最早回傳的一頁;query 先排除 pulldependabotrenovategithub-actions四個 Apps,後處理再移除明顯 bot-shaped 登入、少於五個 distinct words 的說明、同週相同詞集合,並把每位帳號每週上限設為三篇。詞彙至少要出現在 50 個不同帳號,才進入 vocabulary。

模型部分用 KL divergence 的 hard clustering,把每篇 PR 強制放進十群之一,之後才按週計數。作者在固定版本的方法說明也直接列出:K=10是看過結果後選的,seed、五分鐘窗口與每週三篇上限都會影響答案。這份透明度很重要,因為它支持的是「這個規格下出現大型詞彙分布變化」,不是「39% PR 由 Claude 撰寫」。

不要把群體相關性倒推成單篇作者。
原專案收集的公開欄位不含可驗證的生成工具 ground truth;GitHub 帳號只告訴你誰送出文字,不告訴你文字由人、Claude、其他模型、template 或多人共同起草。
詞彙分布訊號不等於 Claude 作者判定的比較圖
分布訊號可以觸發調查;作者判定需要標記 provenance、base rate 與已校正錯誤率,兩者不能畫上等號。

步驟一:先固定自己的 PR corpus 與標記規則

準備 Node.js 22.18.0、jq 與已登入的 gh CLI。不要用「最近 500 篇」代替時間窗:GitHub CLI 官方手冊列出的 flags 沒有保證按 mergedAt 排序,因此不能假設固定筆數就是最近合併的全集。本文會用明確的 merged:開始..結束範圍;結果碰到 GitHub Search 的 1,000 筆上限就視為截斷並停止。手冊列出的 JSON 欄位也包含本文會用到的 numberauthorbodymergedAtlabels

mkdir -p .style-drift artifacts
node --version
gh auth status

接著在 PR 層級選用四個 label。這些值應由提交者或團隊流程標記,不要靠詞彙反推:

  • authorship:human:團隊定義下未使用生成式改寫。
  • authorship:ai-assisted:AI 參與起草、改寫或摘要;不表示整篇全自動生成。
  • authorship:bot:已知自動化帳號或 template 產生。
  • authorship:unknown:未標記、衝突或無法確認,也是預設值。

主分布排除明確標記為 bot 的 PR,其餘 human、AI-assisted、unknown 都保留;報告把完整 input 與經過最少詞數、bot 排除、作者上限後的 scoring sample 分開列出四類數量。如此做的目的不是訓練分類器,而是讓讀者看到 provenance 覆蓋率。即使帳號以 [bot] 結尾,也只是 account-type heuristic,不應自動變成內容作者證據。

步驟二:Claude 語言漂移分數怎麼算?

小型 repo 不適合直接照搬固定十群。本文先做更容易稽核的版本:只分析 PR body;每篇 PR 對每個詞最多貢獻一次,再讓每篇 PR 的總權重相同;每位作者每週最多抽三篇。介入前八個連續完整 UTC 週會被轉成只含聚合分布、vocabulary、設定與來源 SHA-256 的 baseline receipt,之後週報只讀這個版本化檔案,不重新抓歷史文字。這些是工程起始值,不是跨 repo 通用標準。

  1. 這個起始版用簡化規則移除成對的三反引號 code fence、inline code 與 HTML tag,URL 只保留 domain,再做 NFC normalization;複雜或不完整 Markdown 應先換成正式 parser。
  2. 使用 Intl.Segmenter('zh-Hant') 同時處理繁中與英文;每篇 PR 內重複詞去重。
  3. 只有在八個 baseline 週中至少出現於三篇 PR 的詞,才進入凍結 vocabulary;current 內所有不在這份 vocabulary 的詞(包含 baseline 時未達門檻與後來才出現者)都合併成同一個 out-of-vocabulary bucket,不回頭改寫 baseline。
  4. 先把每篇 PR 的詞彙權重正規化,再平均成每週分布;以 log base 2 計算 Jensen–Shannon divergence,開根號後成為 0 到 1 的 distance。
  5. 輪流拿 baseline 每一週對其餘七週計分;經驗 P95 只當粗略視覺線,不是顯著性檢定。歷史累積到 20 週以上,再評估是否有資格設定穩定提醒線。

分數公式可寫成 sqrt(JS(P_current || P_baseline))。它只表示兩個詞彙分布有多不同,不知道變化來自 Claude、prompt、產品名稱、團隊組成或 PR template。若 template 有固定段落,應先移除該區塊或按 template 版本分層。

步驟三:加入可直接執行的 advisory 腳本

建立 scripts/style-drift.mjs,貼上以下完整程式。freeze是一次性設定:日期不連續、每週不足 12 篇、設定無效或 vocabulary 少於 10 詞就以非零結束,不產生 baseline。report則是 fail-open advisory:已處理的資料不足不拿詞彙分數阻擋 merge;collection error 會先留下 tool_error receipt,再由 workflow 最後一步把 job 標紅。Runner、權限、receipt 或整個程序故障也會明確失敗。這份 workflow 沒有 pull_request trigger,因此紅色維運訊號本身仍不會成為 merge gate。

#!/usr/bin/env node

import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

const SCHEMA_VERSION = 2;
const OOV_BUCKET = "__out_of_vocabulary__";
const CONFIG = {
  baselineWeeks: Number(process.env.STYLE_BASELINE_WEEKS || 8),
  minDocsPerWeek: Number(process.env.STYLE_MIN_DOCS || 12),
  maxDocsPerAuthorWeek: Number(process.env.STYLE_MAX_DOCS_PER_AUTHOR || 3),
  minDocumentFrequency: Number(process.env.STYLE_MIN_DF || 3),
  minUniqueTokensPerDocument: Number(process.env.STYLE_MIN_TOKENS || 5),
};

function requireInteger(name, value, min, max) {
  if (!Number.isInteger(value) || value < min || value > max) {
    throw new Error(name + " must be an integer from " + min + " to " + max);
  }
}

function validateConfig(config) {
  requireInteger("STYLE_BASELINE_WEEKS", config.baselineWeeks, 4, 104);
  requireInteger("STYLE_MIN_DOCS", config.minDocsPerWeek, 1, 10000);
  requireInteger("STYLE_MAX_DOCS_PER_AUTHOR", config.maxDocsPerAuthorWeek, 1, 1000);
  requireInteger("STYLE_MIN_DF", config.minDocumentFrequency, 1, 10000);
  requireInteger("STYLE_MIN_TOKENS", config.minUniqueTokensPerDocument, 1, 1000);
}

function parseDate(value, label) {
  const d = new Date(value);
  if (Number.isNaN(d.getTime())) throw new Error("Invalid " + label + ": " + value);
  return d;
}

function weekStart(value) {
  const d = parseDate(value, "date");
  const day = (d.getUTCDay() + 6) % 7;
  d.setUTCDate(d.getUTCDate() - day);
  d.setUTCHours(0, 0, 0, 0);
  return d.toISOString().slice(0, 10);
}

function addDays(isoDate, days) {
  const d = parseDate(isoDate + "T00:00:00Z", "ISO date");
  d.setUTCDate(d.getUTCDate() + days);
  return d.toISOString().slice(0, 10);
}

function latestCompleteWeek() {
  // Regression fixtures pin the clock; production leaves STYLE_NOW unset.
  const now = process.env.STYLE_NOW
    ? parseDate(process.env.STYLE_NOW, "STYLE_NOW")
    : new Date();
  return addDays(weekStart(now), -7);
}

function expectedBaselineWeeks(endWeek, count) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(endWeek) || weekStart(endWeek + "T00:00:00Z") !== endWeek) {
    throw new Error("STYLE_BASELINE_END must be a UTC Monday in YYYY-MM-DD");
  }
  return Array.from({ length: count }, (_, index) =>
    addDays(endWeek, -7 * (count - index - 1)),
  );
}

function declaredAttribution(pr) {
  const values = (pr.labels || [])
    .map((label) => (typeof label === "string" ? label : label?.name || ""))
    .map((label) => label.toLowerCase())
    .filter((label) => label.startsWith("authorship:"))
    .map((label) => label.slice("authorship:".length));
  const allowed = new Set(["human", "ai-assisted", "bot", "unknown"]);
  if (!values.length || values.some((value) => !allowed.has(value))) return "unknown";
  return new Set(values).size === 1 ? values[0] : "unknown";
}

function cleanMarkdown(text) {
  return String(text || "")
    .replace(/\x60\x60\x60[\s\S]*?\x60\x60\x60/g, " ")
    .replace(/\x60[^\x60]*\x60/g, " ")
    .replace(/https?:\/\/[^\s)]+/g, (url) => {
      try {
        return " url:" + new URL(url).hostname + " ";
      } catch {
        return " url ";
      }
    })
    .replace(/<[^>]+>/g, " ")
    .normalize("NFC")
    .toLocaleLowerCase("en-US");
}

const segmenter = new Intl.Segmenter("zh-Hant", { granularity: "word" });

function documentTokens(pr) {
  const text = cleanMarkdown(pr.body || "");
  const tokens = [...segmenter.segment(text)]
    .filter((part) => part.isWordLike)
    .map((part) => part.segment.trim())
    .filter((token) => /[\p{L}\p{N}]/u.test(token))
    .filter((token) => !/^\d+(?:[.,]\d+)*$/.test(token));
  return [...new Set(tokens)];
}

function stableKey(pr) {
  return createHash("sha256")
    .update("style-drift-v2:" + (pr.number ?? "?") + ":" + (pr.mergedAt ?? "?"))
    .digest("hex");
}

function eligibleDocuments(prs, config) {
  return prs.filter((pr) => documentTokens(pr).length >= config.minUniqueTokensPerDocument);
}

function capAuthors(prs, config) {
  const groups = new Map();
  for (const pr of prs) {
    const author = pr.author?.login || "unknown-author";
    const key = weekStart(pr.mergedAt) + ":" + author;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(pr);
  }
  return [...groups.values()].flatMap((group) =>
    group
      .sort((a, b) => stableKey(a).localeCompare(stableKey(b)))
      .slice(0, config.maxDocsPerAuthorWeek),
  );
}

function groupByWeek(prs) {
  const grouped = new Map();
  for (const pr of prs) {
    const week = weekStart(pr.mergedAt);
    if (!grouped.has(week)) grouped.set(week, []);
    grouped.get(week).push(pr);
  }
  return grouped;
}

function documentFrequency(prs) {
  const counts = new Map();
  for (const pr of prs) {
    for (const token of documentTokens(pr)) {
      counts.set(token, (counts.get(token) || 0) + 1);
    }
  }
  return counts;
}

function documentWeights(prs) {
  const weights = new Map();
  for (const pr of prs) {
    const tokens = documentTokens(pr);
    if (!tokens.length) continue;
    for (const token of tokens) {
      weights.set(token, (weights.get(token) || 0) + 1 / tokens.length);
    }
  }
  return weights;
}

function addMaps(maps) {
  const total = new Map();
  for (const map of maps) {
    for (const [token, value] of map) {
      total.set(token, (total.get(token) || 0) + value);
    }
  }
  return total;
}

function project(map, vocabulary) {
  const projected = new Map([...vocabulary].map((token) => [token, 0]));
  for (const [token, value] of map) {
    const target = vocabulary.has(token) ? token : OOV_BUCKET;
    projected.set(target, (projected.get(target) || 0) + value);
  }
  return projected;
}

function probabilities(counts, vocabulary) {
  const denominator = [...vocabulary].reduce(
    (sum, token) => sum + (counts.get(token) || 0),
    0,
  );
  if (!(denominator > 0)) throw new Error("Distribution has zero mass");
  return new Map(
    [...vocabulary].map((token) => [token, (counts.get(token) || 0) / denominator]),
  );
}

function jsDistance(leftCounts, rightCounts, vocabulary) {
  const left = probabilities(leftCounts, vocabulary);
  const right = probabilities(rightCounts, vocabulary);
  let divergence = 0;
  for (const token of vocabulary) {
    const p = left.get(token) || 0;
    const q = right.get(token) || 0;
    const m = (p + q) / 2;
    if (p) divergence += 0.5 * p * Math.log2(p / m);
    if (q) divergence += 0.5 * q * Math.log2(q / m);
  }
  return Math.sqrt(Math.max(0, divergence));
}

function percentile(values, fraction) {
  const sorted = [...values].sort((a, b) => a - b);
  if (!sorted.length) throw new Error("No calibration scores");
  const index = Math.ceil(fraction * sorted.length) - 1;
  return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
}

function mapToObject(map) {
  return Object.fromEntries([...map].sort(([a], [b]) => a.localeCompare(b)));
}

function objectToMap(value, label) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(label + " must be an object");
  }
  const map = new Map();
  for (const [key, item] of Object.entries(value)) {
    if (typeof item !== "number" || !Number.isFinite(item) || item < 0) {
      throw new Error(label + " contains an invalid numeric value");
    }
    map.set(key, item);
  }
  return map;
}

function summarizeAttribution(prs) {
  const summary = { human: 0, "ai-assisted": 0, bot: 0, unknown: 0 };
  for (const pr of prs) summary[declaredAttribution(pr)] += 1;
  return summary;
}

function sourceFingerprint(prs) {
  const rows = prs
    .map((pr) => ({
      number: pr.number ?? null,
      mergedAt: pr.mergedAt ?? null,
      author: pr.author?.login || null,
      body: pr.body || "",
      labels: (pr.labels || [])
        .map((label) => (typeof label === "string" ? label : label?.name || ""))
        .sort(),
    }))
    .sort((a, b) => String(a.number).localeCompare(String(b.number)));
  return createHash("sha256").update(JSON.stringify(rows)).digest("hex");
}

function redactableToken(token) {
  return (
    token.length <= 48 &&
    !token.includes("@") &&
    !token.includes("/") &&
    !/^[a-f0-9]{24,}$/i.test(token) &&
    !/\d{5,}/.test(token)
  );
}

function topChanges(currentCounts, baselineCounts, vocabulary, limit = 8) {
  const current = probabilities(currentCounts, vocabulary);
  const baseline = probabilities(baselineCounts, vocabulary);
  return [...vocabulary]
    .filter((token) => token !== OOV_BUCKET && redactableToken(token))
    .map((token) => ({
      token,
      delta: (current.get(token) || 0) - (baseline.get(token) || 0),
    }))
    .filter((row) => row.delta > 0)
    .sort((a, b) => b.delta - a.delta)
    .slice(0, limit);
}

function validateInputWindow(prs, allowedWeeks, label) {
  const allowed = new Set(allowedWeeks);
  const outside = prs.filter((pr) => !allowed.has(weekStart(pr.mergedAt)));
  if (outside.length) {
    throw new Error(label + " contains " + outside.length + " rows outside the exact window");
  }
}

function buildBaseline(prs, config) {
  validateConfig(config);
  const endWeek = process.env.STYLE_BASELINE_END || "";
  const weeks = expectedBaselineWeeks(endWeek, config.baselineWeeks);
  if (endWeek > latestCompleteWeek()) throw new Error("Baseline cannot include an incomplete UTC week");
  validateInputWindow(prs, weeks, "Baseline input");
  const eligible = eligibleDocuments(prs, config);
  const nonBot = eligible.filter((pr) => declaredAttribution(pr) !== "bot");
  const selected = capAuthors(nonBot, config);
  const grouped = groupByWeek(selected);
  const documents = Object.fromEntries(
    weeks.map((week) => [week, (grouped.get(week) || []).length]),
  );
  const insufficient = weeks.filter((week) => documents[week] < config.minDocsPerWeek);
  if (insufficient.length) {
    throw new Error("Baseline weeks below STYLE_MIN_DOCS: " + insufficient.join(", "));
  }
  const weeklyDf = new Map(
    weeks.map((week) => [week, documentFrequency(grouped.get(week) || [])]),
  );
  const baselineDf = addMaps(weeks.map((week) => weeklyDf.get(week)));
  const tokens = [...baselineDf]
    .filter(
      ([token, count]) =>
        token !== OOV_BUCKET && count >= config.minDocumentFrequency,
    )
    .map(([token]) => token)
    .sort();
  if (tokens.length < 10) {
    throw new Error("insufficient_vocabulary: fewer than 10 baseline tokens passed STYLE_MIN_DF");
  }
  const vocabulary = new Set([...tokens, OOV_BUCKET]);
  const weeklyWeights = new Map(
    weeks.map((week) => [week, documentWeights(grouped.get(week) || [])]),
  );
  const baselineWeights = project(
    addMaps(weeks.map((week) => weeklyWeights.get(week))),
    vocabulary,
  );
  const baselineDistribution = probabilities(baselineWeights, vocabulary);
  const calibrationScores = weeks.map((week) => {
    const rest = addMaps(
      weeks.filter((other) => other !== week).map((other) => weeklyWeights.get(other)),
    );
    return jsDistance(
      project(weeklyWeights.get(week), vocabulary),
      project(rest, vocabulary),
      vocabulary,
    );
  });
  return {
    schema_version: SCHEMA_VERSION,
    kind: "style-drift-baseline",
    created_at: new Date().toISOString(),
    baseline_end_week: endWeek,
    baseline_weeks: weeks,
    config,
    tokenizer: {
      source_field: "body",
      unicode_normalization: "NFC",
      segmentation: "Intl.Segmenter(zh-Hant, word)",
      code_removed: true,
      document_weighting: "equal",
    },
    source: {
      input_rows: prs.length,
      eligible_rows: eligible.length,
      selected_rows: selected.length,
      sha256: sourceFingerprint(prs),
    },
    input_attribution: summarizeAttribution(prs),
    selected_attribution: summarizeAttribution(selected),
    documents,
    vocabulary: tokens,
    distribution: mapToObject(baselineDistribution),
    calibration_scores: calibrationScores,
    advisory_reference_p95: percentile(calibrationScores, 0.95),
  };
}

function validateBaseline(baseline) {
  if (baseline?.schema_version !== SCHEMA_VERSION || baseline?.kind !== "style-drift-baseline") {
    throw new Error("Baseline schema/kind mismatch");
  }
  validateConfig(baseline.config);
  const expected = expectedBaselineWeeks(
    baseline.baseline_end_week,
    baseline.config.baselineWeeks,
  );
  if (JSON.stringify(expected) !== JSON.stringify(baseline.baseline_weeks)) {
    throw new Error("Baseline weeks are not consecutive or do not match config");
  }
  if (
    !Array.isArray(baseline.vocabulary) ||
    baseline.vocabulary.length < 10 ||
    baseline.vocabulary.some(
      (token) => typeof token !== "string" || !token || token === OOV_BUCKET,
    ) ||
    new Set(baseline.vocabulary).size !== baseline.vocabulary.length
  ) {
    throw new Error("insufficient_vocabulary: baseline vocabulary is degenerate");
  }
  const vocabulary = new Set([...baseline.vocabulary, OOV_BUCKET]);
  const distribution = objectToMap(baseline.distribution, "baseline.distribution");
  for (const token of vocabulary) {
    if (!distribution.has(token)) {
      throw new Error("Baseline distribution does not cover the frozen vocabulary");
    }
  }
  probabilities(distribution, vocabulary);
  if (
    typeof baseline.advisory_reference_p95 !== "number" ||
    !Number.isFinite(baseline.advisory_reference_p95) ||
    baseline.advisory_reference_p95 < 0 ||
    baseline.advisory_reference_p95 > 1
  ) {
    throw new Error("Baseline advisory reference is invalid");
  }
  return { vocabulary, distribution };
}

function baseReport(status, baseline, inputPrs, extra = {}) {
  return {
    schema_version: SCHEMA_VERSION,
    kind: "style-drift-report",
    policy: "advisory-only",
    status,
    expected_current_week: latestCompleteWeek(),
    baseline_end_week: baseline?.baseline_end_week || null,
    input_rows: inputPrs?.length || 0,
    ...extra,
  };
}

function buildReport(prs, baseline) {
  const { vocabulary, distribution } = validateBaseline(baseline);
  const currentWeek = latestCompleteWeek();
  if (baseline.baseline_end_week >= currentWeek) {
    return baseReport("insufficient_history", baseline, prs);
  }
  validateInputWindow(prs, [currentWeek], "Current input");
  const eligible = eligibleDocuments(prs, baseline.config);
  const nonBot = eligible.filter((pr) => declaredAttribution(pr) !== "bot");
  const selected = capAuthors(nonBot, baseline.config);
  const inputAttribution = summarizeAttribution(prs);
  const selectedAttribution = summarizeAttribution(selected);
  if (selected.length < baseline.config.minDocsPerWeek) {
    return baseReport("insufficient_documents", baseline, prs, {
      current_week: currentWeek,
      eligible_rows: eligible.length,
      selected_rows: selected.length,
      input_attribution: inputAttribution,
      selected_attribution: selectedAttribution,
    });
  }
  const currentWeightsRaw = documentWeights(selected);
  const currentWeights = project(currentWeightsRaw, vocabulary);
  const currentDistribution = probabilities(currentWeights, vocabulary);
  const score = jsDistance(currentDistribution, distribution, vocabulary);
  const threshold = baseline.advisory_reference_p95;
  const includeTokens = process.env.STYLE_INCLUDE_TOKENS === "1";
  let topRising = [];
  if (includeTokens) {
    const currentDf = documentFrequency(selected);
    const diagnosticVocabulary = new Set([
      ...baseline.vocabulary,
      ...[...currentDf]
        .filter(([, count]) => count >= baseline.config.minDocumentFrequency)
        .map(([token]) => token),
    ]);
    topRising = topChanges(
      currentWeightsRaw,
      objectToMap(baseline.distribution, "baseline.distribution"),
      diagnosticVocabulary,
    );
  }
  return baseReport(
    score > threshold ? "review_suggested" : "within_reference",
    baseline,
    prs,
    {
      current_week: currentWeek,
      eligible_rows: eligible.length,
      selected_rows: selected.length,
      input_attribution: inputAttribution,
      selected_attribution: selectedAttribution,
      score,
      advisory_reference_p95: threshold,
      vocabulary_size: baseline.vocabulary.length,
      out_of_vocabulary_share: currentDistribution.get(OOV_BUCKET) || 0,
      diagnostics_redacted: !includeTokens,
      top_rising: topRising,
      interpretation: "A distribution alert is not authorship evidence or comprehension evidence.",
    },
  );
}

function markdownSummary(report) {
  const lines = [
    "## PR Style Drift CI(advisory)",
    "",
    "- 狀態:**" + report.status + "**",
    ...(report.error ? ["- 工具錯誤:" + report.error] : []),
    "- 預期 current week:" + (report.expected_current_week || "n/a"),
    "- 分數:" + (report.score == null ? "n/a" : report.score.toFixed(4)),
    "- baseline 參考線:" +
      (report.advisory_reference_p95 == null ? "n/a" : report.advisory_reference_p95.toFixed(4)),
    "- input 標記:" + JSON.stringify(report.input_attribution || {}),
    "- scoring 標記:" + JSON.stringify(report.selected_attribution || {}),
    "- out-of-vocabulary share:" +
      (report.out_of_vocabulary_share == null
        ? "n/a"
        : report.out_of_vocabulary_share.toFixed(4)),
    "",
    "### 詞彙診斷",
    "",
    ...(report.diagnostics_redacted
      ? ["- 預設隱藏 exact tokens;只在存取受控環境設 STYLE_INCLUDE_TOKENS=1。"]
      : report.top_rising?.length
        ? report.top_rising.map((row) => "- " + row.token + ": +" + row.delta.toFixed(5))
        : ["- 無可顯示詞彙"]),
    "",
    "### 人工抽查",
    "",
    "先定義最小重要差異與評分規則,再用盲化、隨機分派的 reviewer test。小樣本無法排除傷害時,結果記為 inconclusive。",
    "",
    "> 本報告只提示分布偏移,不阻擋合併,也不判定 Claude 或任何人的作者身分。",
  ];
  return lines.join("\n");
}

function writeJsonStrict(path, value) {
  mkdirSync(dirname(path), { recursive: true });
  writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
}

function emitReport(report, outputPath) {
  let receiptError = null;
  try {
    writeJsonStrict(outputPath, report);
  } catch (error) {
    receiptError = String(error?.message || error);
  }
  const summary =
    markdownSummary(report) +
    (receiptError ? "\n\n> Receipt write failed: " + receiptError : "");
  try {
    if (process.env.GITHUB_STEP_SUMMARY) {
      mkdirSync(dirname(process.env.GITHUB_STEP_SUMMARY), { recursive: true });
      writeFileSync(process.env.GITHUB_STEP_SUMMARY, summary + "\n", { flag: "a" });
    }
  } catch (error) {
    process.stderr.write(
      "Could not append GITHUB_STEP_SUMMARY: " + String(error?.message || error) + "\n",
    );
  }
  process.stdout.write(summary + "\n");
  if (receiptError) process.stdout.write(JSON.stringify(report) + "\n");
}

function readJson(path, label) {
  let value;
  try {
    value = JSON.parse(readFileSync(path, "utf8"));
  } catch {
    throw new Error(label + " is not valid JSON");
  }
  if (!value || typeof value !== "object") throw new Error(label + " must be JSON");
  return value;
}

function toolError(error, baseline, outputPath) {
  const report = baseReport("tool_error", baseline, [], {
    error: String(error?.message || error),
    input_attribution: {},
    selected_attribution: {},
    diagnostics_redacted: true,
    top_rising: [],
  });
  emitReport(report, outputPath);
}

function printWindow() {
  const start = latestCompleteWeek();
  process.stdout.write("STYLE_CURRENT_START=" + start + "\n");
  process.stdout.write("STYLE_CURRENT_END=" + addDays(start, 6) + "\n");
}

function usage() {
  process.stderr.write(
    "Usage:\n" +
      "  node style-drift.mjs window\n" +
      "  STYLE_BASELINE_END=YYYY-MM-DD node style-drift.mjs freeze input.json baseline.json\n" +
      "  node style-drift.mjs report current.json baseline.json report.json\n",
  );
}

const [mode, ...args] = process.argv.slice(2);

if (mode === "window") {
  printWindow();
} else if (mode === "freeze") {
  const [inputPath, baselinePath] = args;
  if (!inputPath || !baselinePath) {
    usage();
    process.exitCode = 2;
  } else {
    try {
      const prs = readJson(inputPath, "Baseline input");
      if (!Array.isArray(prs)) throw new Error("Baseline input must be a JSON array");
      const baseline = buildBaseline(prs, CONFIG);
      writeJsonStrict(baselinePath, baseline);
      process.stdout.write(
        "Frozen baseline: " + baseline.source.selected_rows + " rows, " +
          baseline.vocabulary.length + " tokens, SHA-256 " + baseline.source.sha256 + "\n",
      );
    } catch (error) {
      process.stderr.write("Baseline freeze failed: " + String(error?.message || error) + "\n");
      process.exitCode = 1;
    }
  }
} else if (mode === "report") {
  const [currentPath, baselinePath, outputPath = "artifacts/style-drift.json"] = args;
  if (!currentPath || !baselinePath) {
    usage();
    process.exitCode = 2;
  } else {
    let baseline = null;
    try {
      baseline = readJson(baselinePath, "Baseline");
      if (
        process.env.STYLE_COLLECTION_STATUS &&
        process.env.STYLE_COLLECTION_STATUS !== "success"
      ) {
        throw new Error("Collection step status was " + process.env.STYLE_COLLECTION_STATUS);
      }
      const prs = readJson(currentPath, "Current input");
      if (!Array.isArray(prs)) throw new Error("Current input must be a JSON array");
      emitReport(buildReport(prs, baseline), outputPath);
    } catch (error) {
      toolError(error, baseline, outputPath);
    }
  }
} else {
  usage();
  process.exitCode = 2;
}

先用明確日期抓八個 baseline 週。以下例子以 2026 年 8 月 10 日當最後一週的 Monday,因此搜尋到 8 月 16 日;達到 1,000 筆就停止,改用可驗證完整性的 API 枚舉,或拆成更細且不重疊的日期區間後依 PR number 去重。

(
  set -euo pipefail

  gh pr list --state merged \
    --search "merged:2026-06-22..2026-08-16" \
    --limit 1000 \
    --json number,author,body,mergedAt,labels \
    > .style-drift/baseline-prs.json

  test "$(jq length .style-drift/baseline-prs.json)" -lt 1000

  STYLE_BASELINE_END=2026-08-10 node scripts/style-drift.mjs freeze \
    .style-drift/baseline-prs.json \
    .style-drift/baseline.json
)

確認 baseline.json不含 raw body 或作者名稱後,把它與腳本一起納入版本控制;原始 *-prs.json必須加入 .gitignore並依內部資料保留政策刪除。Receipt 仍含 exact vocabulary,敏感 repo 應維持 private 並限制讀取。本文用 170 篇合成 PR fixture 跑過 13 個 regression 情境:凍結後的歷史文字變更不改分數、缺週/空詞表/重複 vocabulary/超出 0–1 的 reference 被拒絕、OOV 保留字碰撞不會產生自相矛盾的 receipt、stale current window/collection error 變成 tool_error、標記衝突變成 unknown、receipt 寫入失敗仍留 stdout。這只驗證教學程式的機械行為,沒有下載或重跑原專案完整每日 corpus,也不構成真實 repo 的效果結論。

步驟四:把週報接進 GitHub Actions,但永遠不擋 PR

建立 .github/workflows/style-drift.yml。每週一先由腳本算出上一個完整 UTC calendar week,再用精確 merged range 收集;Markdown 寫入 GITHUB_STEP_SUMMARY,非隱藏的 artifacts/style-drift.json才上傳。Exact tokens 預設不輸出;若要在受控 private repo 內除錯,才手動設 STYLE_INCLUDE_TOKENS=1

name: PR style drift (advisory)

on:
  schedule:
    - cron: '17 2 * * 1'
  workflow_dispatch:

permissions:
  contents: read
  pull-requests: read

jobs:
  report:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09

      - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
        with:
          node-version: '22.18.0'

      - name: Resolve the latest complete UTC week
        run: node scripts/style-drift.mjs window >> "$GITHUB_ENV"

      - name: Collect the exact merged-date range
        id: collect
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          mkdir -p .style-drift artifacts
          gh pr list --state merged \
            --search "merged:${STYLE_CURRENT_START}..${STYLE_CURRENT_END}" \
            --limit 1000 \
            --json number,author,body,mergedAt,labels \
            > .style-drift/current-prs.json
          test "$(jq length .style-drift/current-prs.json)" -lt 1000

      - name: Build advisory report
        env:
          STYLE_COLLECTION_STATUS: ${{ steps.collect.outcome }}
        run: |
          node scripts/style-drift.mjs report \
            .style-drift/current-prs.json \
            .style-drift/baseline.json \
            artifacts/style-drift.json

      - name: Keep aggregate receipt only
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
        with:
          name: style-drift-${{ github.run_id }}-${{ github.run_attempt }}
          path: artifacts/style-drift.json
          if-no-files-found: error
          retention-days: 30

      - name: Surface operational failure after preserving receipt
        if: always()
        env:
          COLLECTION_OUTCOME: ${{ steps.collect.outcome }}
        run: |
          failed=0
          if [ "$COLLECTION_OUTCOME" != "success" ]; then
            echo "::error::PR collection failed; inspect the receipt."
            failed=1
          fi
          if [ ! -f artifacts/style-drift.json ]; then
            echo "::error::Style drift receipt is missing."
            failed=1
          else
            status=$(jq -er '.status // empty' artifacts/style-drift.json 2>/dev/null) || status=""
            case "$status" in
              within_reference|review_suggested|insufficient_documents|insufficient_history) ;;
              *)
                echo "::error::Style drift report ended in ${status:-invalid_status}."
                failed=1
                ;;
            esac
          fi
          exit "$failed"

若 repo 每週不到 12 篇,這個版本刻意回報 insufficient_documents,不會暗中把 current 擴成多週。要改成四週窗口,必須把 window 定義、baseline pseudo-window 與 regression fixture 一起改版,不能只調一個環境變數。忙碌 repo 若單週碰到 1,000 筆上限,則改用可驗證完整性的 API 枚舉,或切成更細且不重疊的日期區間;不要接受被截斷的結果。

步驟五:用盲化 reviewer comprehension 判定 harm、no detected harm 或 inconclusive

詞彙漂移和審查品質是兩個不同問題。當 CI 顯示 review_suggested,可先抽 6 至 10 篇 PR 做流程 pilot:預先寫答案 key、確認題目可評分、估計變異,但不能用這個小樣本宣稱兩版等效。正式比較要先定義團隊在意的最小差距 Δ 與樣本計畫;每個 reviewer 對同一篇 PR 只看 A 或 B 其中一版,再在整個池中隨機分派與平衡條件,避免先看到一版而記住答案。Reviewer 不看版本來源,只回答:

  1. 這個 PR 改了什麼?
  2. 主要風險、限制或回滾點是什麼?
  3. 審查後的下一個動作是什麼?

每題 0 至 2 分,另外記錄完成秒數與澄清問題數。不要只測句長或禁字命中率。若需要建立 Claude Code 的 style A/B,可沿用《Claudish 與 Output Style A/B Test 教學》的 session 固定與資訊保真方法;這篇只把 style prompt 當實驗變因,不重教 Output Style。

「沒有看到變差」不等於「證明沒變差」。可以沿用 equivalence testing 的基本思路:先定義正確率或完成時間可接受的最小重要差距 Δ;只有估計區間完整落在 內,才記 no_detected_harm。區間太寬就記 inconclusive;明確跨過傷害界線才記 harm_detected。把每次結果存成不含作者名稱、預設不含 exact tokens 的 review-outcomes.csv

week,alert_id,score,reference,design,n,delta,effect_low,effect_high,outcome,composition_change,note
Style Drift CI 的盲化 A/B 理解測試與 inconclusive 判定流程
同一個 diff 只讓每位 reviewer 看其中一版;先量三題正確率與作答時間,再依預先設定的等效界線判讀。

如何解讀六種結果,而不是做禁字表?

  • 偏移是 × harm_detected:追查 prompt、template、PR 類型與漏掉的風險資訊;先修內容規格。
  • 偏移是 × no_detected_harm:記為 false-positive candidate;累積重複結果後再校正,不處罰用詞。
  • 偏移是 × inconclusive:維持 shadow mode,增加預先規劃的樣本,不偷改 Δ。
  • 偏移否 × harm_detected:詞彙指標漏報;補看結構、事實保真與任務品質。
  • 偏移否 × no_detected_harm:留在 baseline 內;持續監控 composition 與 template 版本。
  • 偏移否 × inconclusive:理解實驗本身資訊不足;先改善設計,不拿詞彙分數代替答案。

這裡最容易犯的錯,是為了讓 score 下降而要求 Claude 避免若干單字。Lexical constraint 可能把表達推向變形、同義詞或繞句,但不保證資訊更清楚。真正可驗收的 prompt 應該寫正向要求,例如「明確寫出改動機制、主要風險、測試證據與下一步」,再看理解題是否改善。

Prompt 變更也可能外溢到任務能力。Anthropic 在 2026 年 4 月事故回顧中報告:以更廣的評估集做 ablation 時,其中一項 evaluation 在 Opus 4.6 與 4.7 都下降 3%,因此相關 verbosity prompt 隨 4 月 20 日版本撤回。這不是所有評估平均下降 3%。所以 A/B 除了可讀性,仍要保留測試是否正確、風險是否完整與 code quality 指標。想理解規則、工具與驗收層如何包住模型,可先讀《AI Agent Harness 是什麼》與《AI Agent Harness 實作教學》。

Style Drift CI 和 Anthropic 文字浮水印有什麼不同?

Anthropic 在 2026 年 8 月 14 日的文字浮水印公告說明,未來 Claude 模型會在不加入隱藏字元或 token 的情況下產生統計訊號,舊模型則預計逐步加入。官方同時限定:偵測器最多指出 Claude 可能參與,不能判定作品所有權、特定使用者、組織或對話;短樣本、程式碼與輕度編輯的訊號也較弱。

這和本文完全不同。浮水印是模型生成時加入的 provenance signal;Style Drift CI 是你對 repo corpus 做的事後分布比較。後者沒有浮水印金鑰、標記真值與已校正錯誤率。SynthID-Text 的 Nature 論文也區分生成時浮水印與 post-hoc classifier,並把生成式浮水印定位成 detection 的互補方法,而非完整解法。不要把詞彙群誤叫成 watermark。

Claude 語言漂移常見問題 FAQ

1. 看到 load-bearing 就能判定 Claude 寫的嗎?

不能。它在原資料中是群體層級的相關詞,既不專屬 Claude,也可能由人類、其他模型或團隊慣用語產生。單篇作者需要獨立 provenance,不能由一個 token 決定。

2. 為什麼不用 AI detector 直接分類?

因為分布漂移回答的是 P_t(X) 是否改變,作者分類需要有標記資料、base rate 與校正過的 P(Claude|X)。兩個問題不是同一個估計量。

3. 八週 baseline 足夠嗎?

只夠啟動 shadow mode,P95 也會很粗。累積到 20 個以上完整週後,另開版本重新凍結 baseline、重跑 regression,再比較門檻;不要覆寫舊 receipt。PR 類型、template 或季節性差異明顯時,應分層建立各自 baseline。

4. 中文 PR 可以使用這份腳本嗎?

可以,腳本使用 Intl.Segmenter('zh-Hant'),也保留英文工程詞。不過專有名詞、日文或無空格語言的切分仍要用你的 fixture 抽查,不要假設 tokenizer 永遠正確。

5. unknown 要不要算成人工?

不要。Unknown 的價值正是保留未知;本文把它納入團隊整體 prose 分布,但在 attribution coverage 中單獨報告。

6. CI 何時可以改成 blocking?

詞彙分數本身不應 blocking。若長期實驗證明某個客觀缺項穩定造成理解失敗,可以另設「缺少測試證據/回滾點」之類的內容 gate;仍不要以 Claude-like 單字阻擋合併。

7. 可以拿報告比較不同工程師嗎?

不建議。小樣本作者排名會放大隱私、誤判與團隊壓力。保留週級聚合、設定每位作者上限,人工表也不要存姓名。

8. 既有 Output Style 或寫作規則還有用嗎?

有,但它們是介入變因,不是評分真值。可先用《AI 寫作去 AI 味系統》整理正向規則,再用本文的分布與理解雙軌驗收;若 context 越塞越長,也要搭配《Claude 怎麼省 token》檢查成本與規則層級。

五個重點帶走

  1. Claude 語言漂移是 corpus 分布問題,不是單字作者鑑定。
  2. 小型 repo 先用等 PR 權重、作者週上限與自己的凍結 baseline,不照搬公開專案的固定十群。
  3. Human、AI-assisted、bot、unknown 都要保留;unknown 絕不自動等於 human。
  4. CI 只寫 advisory summary;已處理的報告狀態維持 exit code 0,基礎設施故障則明確失敗,真正決策來自盲化 reviewer comprehension。
  5. 小樣本看不出傷害時先記 inconclusive;達到預設等效界線的重複結果,才是 false-positive candidate。

想把這套量測接到更多 Agent 開發流程,可瀏覽 AlphaLab AI 專區;需要循序練習完整工作流,則可從 AlphaLab 線上課程開始。

現在就做:先跑四週 shadow report,不改任何人的字

今天先建立四個 attribution labels、用連續八週資料產生並版本化 baseline receipt,再讓 workflow 跑四週 shadow report。第一次警示只用 6 至 10 篇 PR 測試題目、答案 key 與隨機分派流程,結果一律先當 pilot,不宣稱等效。四週後再預先訂 Δ、樣本數與停止規則,問警示是否真的預測理解下降。若答案還不清楚,就記 inconclusive 並讓它繼續 advisory;一個誠實的未知,比一張看似精準的 Claude 作者排行榜更有用。

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多三封:一封 Weekly 週報與最多兩封關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。