// mooncassette/drift —— 两次录制之间的漂移检测。
//
// 场景:你更新了 prompt 或换了模型版本,重新录制了一份 cassette。
// 直接看 JSON diff 是没法读的(指纹变了、key 顺序也变了),
// 你真正想知道的是三件事:
// 1. 哪些调用消失了(prompt 改了 / 被删了);
// 2. 哪些调用是新增的;
// 3. 哪些调用**请求没变但响应变了** —— 这才是「模型行为漂移」。
//
// 第 3 类尤其重要:它意味着你没改代码,但模型输出变了。
///|
/// 漂移的类型。
pub(all) enum DriftKind {
/// 只出现在旧录制中:该调用被删除,或请求被改动导致指纹变化。
Removed
/// 只出现在新录制中:新增调用,或请求被改动导致指纹变化。
Added
/// 两侧都有同一请求,但响应内容不同 —— 真正的「行为漂移」。
Changed
} derive(Eq)
///|
/// 一条漂移记录。
pub(all) struct DriftEntry {
kind : DriftKind
/// 请求指纹(短形式,便于人眼比对)。
fingerprint : String
/// 该请求所属的模型名。
model : String
/// 在旧录制中的下标;`Removed` / `Changed` 时存在。
old_index : Int?
/// 在新录制中的下标;`Added` / `Changed` 时存在。
new_index : Int?
} derive(Eq)
///|
/// 漂移报告。
///
/// 条目顺序是确定的:先 `Removed`,再 `Changed`,最后 `Added`;
/// 每组内部按各自的下标升序。这样报告可以直接用于 diff 或快照测试。
pub(all) struct DriftReport {
entries : Array[DriftEntry]
/// 两侧完全一致的请求条数。
unchanged : Int
} derive(Eq)
///|
/// 是否存在任何漂移。
pub fn DriftReport::is_clean(self : DriftReport) -> Bool {
self.entries.length() == 0
}
///|
/// 统计某一类漂移的条数。
pub fn DriftReport::count_of(self : DriftReport, kind : DriftKind) -> Int {
let mut total = 0
for entry in self.entries {
if entry.kind == kind {
total = total + 1
}
}
total
}
///|
/// 一行式摘要,适合作为日志或 CI 输出。
pub fn DriftReport::summary(self : DriftReport) -> String {
if self.is_clean() {
return "no drift: " +
self.unchanged.to_string() +
" interaction(s) identical"
}
"drift detected: removed=" +
self.count_of(Removed).to_string() +
" changed=" +
self.count_of(Changed).to_string() +
" added=" +
self.count_of(Added).to_string() +
" unchanged=" +
self.unchanged.to_string()
}
///|
/// 逐条渲染报告,每行一条。
pub fn DriftReport::lines(self : DriftReport) -> Array[String] {
let out : Array[String] = []
for entry in self.entries {
out.push(render_entry(entry))
}
out
}
///|
/// 比较两份录制,产出漂移报告。
///
/// 匹配依据是**请求指纹**(即规范化后的请求),因此:
/// - 对象键顺序变化、`request_id` 之类的易变字段变化,都不会被误判成漂移;
/// - 请求本身改动则会表现为「一条 Removed + 一条 Added」,这是刻意的:
/// 我们无法判断改后的请求「对应」原来哪一条,与其猜,不如如实报告。
///
/// 同一指纹出现多次时,按**出现顺序**两两配对,从而正确处理
/// 「同一请求被调用多次」的情形。
pub fn compare(old : @core.Cassette, new : @core.Cassette) -> DriftReport {
let new_buckets : Map[String, Array[Int]] = Map([])
for i = 0; i < new.interactions.length(); i = i + 1 {
let key = request_key(new.interactions[i])
match new_buckets.get(key) {
Some(bucket) => bucket.push(i)
None => {
let bucket : Array[Int] = [i]
new_buckets.set(key, bucket)
}
}
}
let consumed : Map[String, Int] = Map([])
let taken : Array[Bool] = Array::make(new.interactions.length(), false)
let removed : Array[DriftEntry] = []
let changed : Array[DriftEntry] = []
let mut unchanged = 0
for i = 0; i < old.interactions.length(); i = i + 1 {
let item = old.interactions[i]
let key = request_key(item)
let offset = consumed.get_or_default(key, 0)
let candidate = match new_buckets.get(key) {
Some(bucket) =>
if offset < bucket.length() {
Some(bucket[offset])
} else {
None
}
None => None
}
match candidate {
Some(j) => {
consumed.set(key, offset + 1)
taken[j] = true
if response_text(item) == response_text(new.interactions[j]) {
unchanged = unchanged + 1
} else {
changed.push({
kind: Changed,
fingerprint: short_fingerprint(key),
model: item.request.model,
old_index: Some(i),
new_index: Some(j),
})
}
}
None =>
removed.push({
kind: Removed,
fingerprint: short_fingerprint(key),
model: item.request.model,
old_index: Some(i),
new_index: None,
})
}
}
let added : Array[DriftEntry] = []
for j = 0; j < new.interactions.length(); j = j + 1 {
if !taken[j] {
let item = new.interactions[j]
added.push({
kind: Added,
fingerprint: short_fingerprint(request_key(item)),
model: item.request.model,
old_index: None,
new_index: Some(j),
})
}
}
// 固定顺序输出:Removed → Changed → Added。
let entries : Array[DriftEntry] = []
append_all(entries, removed)
append_all(entries, changed)
append_all(entries, added)
{ entries, unchanged }
}
///|
/// 请求的匹配键:规范化请求的指纹。
///
/// 这里**主动再规范化一次**。正常录制出来的 cassette 请求已是规范化形态,
/// 再规范化是幂等的空操作;但对于手工构造、或被旧版本/外部工具改过的
/// cassette,这一步能避免把「易变字段残留」误判成漂移。
/// 漂移检测比较的是语义,因此容错方向应当是「宁可少报」。
fn request_key(interaction : @core.Interaction) -> String {
@fingerprint.fingerprint(interaction.request.normalize())
}
///|
/// 响应内容比较用的规范文本。
///
/// 用规范文本而不是结构相等,是为了让「键顺序不同但语义相同」的响应
/// 不被误判为漂移。
fn response_text(interaction : @core.Interaction) -> String {
@canon.to_canonical_string(interaction.response.to_json())
}
///|
/// 指纹的短形式(取算法名之后的 8 位十六进制),便于日志与报告排版。
fn short_fingerprint(fingerprint : String) -> String {
let parts : Array[StringView] = fingerprint.split(":").to_array()
if parts.length() != 2 {
return fingerprint
}
let digest = parts[1].to_owned()
// 十六进制全是 ASCII,因此按下标切片安全。
if digest.length() <= 8 {
digest
} else {
digest[:8].to_owned()
}
}
///|
fn append_all(target : Array[DriftEntry], source : Array[DriftEntry]) -> Unit {
for item in source {
target.push(item)
}
}
///|
/// 渲染一条漂移记录。
pub fn render_entry(entry : DriftEntry) -> String {
match entry.kind {
Removed =>
"[removed] " +
entry.model +
" #" +
index_text(entry.old_index) +
" " +
entry.fingerprint
Added =>
"[added] " +
entry.model +
" #" +
index_text(entry.new_index) +
" " +
entry.fingerprint
Changed =>
"[changed] " +
entry.model +
" #" +
index_text(entry.old_index) +
" -> #" +
index_text(entry.new_index) +
" " +
entry.fingerprint
}
}
///|
fn index_text(index : Int?) -> String {
match index {
Some(value) => value.to_string()
None => "-"
}
}