// mooncassette/matcher —— 把一次请求对应到一条录制记录。
//
// 匹配是「策略」而非「规范化」:规范化决定两个请求在语义上是否相同,
// 而匹配策略决定在不可完全复现时允许放宽到什么程度。
// 二者分层,避免把宽松规则混进指纹,导致指纹失去稳定性。
///|
/// 回放时使用的匹配策略。
pub(all) enum MatchPolicy {
/// 严格:指纹相同**且**规范请求全等。默认值,推荐。
Exact
/// 宽松:只比较指纹,跳过规范文本比对。
///
/// 与 `Exact` 的差别只有一处:指纹相同之后不再用规范文本确认。因此它省掉的
/// 是**校验**而不是**计算**——指纹本身仍要从规范文本导出,「比较指纹」并不比
/// 「比较规范文本」便宜(实测两者相当,见 `examples/benchmarks`)。
///
/// 代价是理论上可能命中哈希碰撞产生的错误记录。除非确有理由,否则请用 `Exact`。
FingerprintOnly
/// 子集:只比较请求体顶层的指定字段,其余字段一律忽略。
///
/// 适用于「只要 prompt 一致就算同一次调用」这类场景。
///
/// 放宽的范围**仅限请求体**:`provider` 与 `model` 仍然必须相同。
/// 换了模型就不再是同一次调用,把它也算进「可忽略」会让两个不同模型的
/// 记录互相误命中 —— 而那种错误是静默的。
Subset(Array[String])
/// 顺序:不做任何请求比对,按录制顺序依次消费。
///
/// 适用于调用顺序本身即语义的场景(例如流式多轮)。
Sequential
} derive(Eq)
///|
/// 策略名,用于日志与诊断输出。
///
/// 单独提供而不复用 `Show`:诊断报告需要的是稳定、可断言的短标识,
/// 而 `Show` 的输出格式属于展示细节,将来可能调整。
pub fn MatchPolicy::name(self : MatchPolicy) -> String {
match self {
Exact => "Exact"
FingerprintOnly => "FingerprintOnly"
Subset(_) => "Subset"
Sequential => "Sequential"
}
}
///|
/// 该策略是否通过**整请求指纹**筛选候选。
///
/// 用来判断「这次匹配值不值得建指纹索引」:`Exact` 与 `FingerprintOnly` 逐条
/// 比较整请求指纹,索引能把「每条一次的重算」降为「整表一次」;而 `Sequential`
/// 根本不比对请求,`Subset` 比较的是子集投影(投影依赖 `keys`,不是整请求
/// 指纹),给它们建索引只是白白付一次 O(记录数) 的代价。
pub fn MatchPolicy::scans_by_fingerprint(self : MatchPolicy) -> Bool {
match self {
Exact => true
FingerprintOnly => true
Subset(_) => false
Sequential => false
}
}
///|
/// 一次成功匹配的结果。
pub(all) struct MatchResult {
/// 命中的记录在 cassette 中的下标,调用方据此推进游标。
index : Int
interaction : @core.Interaction
} derive(Eq)
///|
/// 从 `cursor` 处开始查找匹配的记录。
///
/// 查找顺序为 `cursor, cursor+1, ..., 末尾, 开头, ..., cursor-1`(环形)。
/// 这样既能正确处理「同一请求被录制多次、按调用次序依次回放」,
/// 又能在实际调用次数多于录制次数时复用最早的一条,而不是直接失败。
///
/// 返回 `None` 表示当前 cassette 中没有候选。是否把它当作错误,
/// 由调用方决定(回放模式报错,自动模式回落真实调用)。
///
/// 只有当**指纹相同而规范请求不同**(即真实哈希碰撞)时才抛错:
/// 那是数据层面的异常,静默容忍会掩盖问题。
///
/// `index` 可选:传入 `MatchIndex` 可复用已算好的指纹(见 `MatchIndex` 的说明)。
/// 省略时逐条现算,结果完全一致,只是慢。重复查询同一份 cassette 时应当传入。
///
/// 传入的索引只在**条数与这批记录相同**时才被采用;否则忽略它并逐条现算。
/// 见下方 `usable_index` 处的说明。
pub fn find_match(
request : @core.Request,
interactions : ArrayView[@core.Interaction],
policy : MatchPolicy,
cursor : Int,
index? : MatchIndex,
) -> MatchResult? raise @core.CassetteError {
let total = interactions.length()
if total == 0 {
return None
}
// 条数对不上就丢掉索引,改用逐条现算。
//
// 这不是防御性冗余:索引短一截会在取指纹时越界;而条数**恰好相同、内容不同**
// 时连越界都不会发生 —— 它只会匹配到错误的记录,也就是「回放出了另一个响应」。
// 现算慢,但答案一定对;而错误答案没有任何代价上限。
let usable_index = match index {
Some(built) =>
if built.aligned_with(interactions) {
Some(built)
} else {
None
}
None => None
}
match policy {
// 顺序模式**不做**游标归一:游标越界即代表录制已耗尽,
// 必须如实报告,否则「录制覆盖不足」会被悄悄掩盖。
Sequential =>
if cursor >= 0 && cursor < total {
Some({ index: cursor, interaction: interactions[cursor] })
} else {
None
}
Exact =>
search_by_text(
request,
interactions,
clamp_cursor(cursor, total),
usable_index,
)
FingerprintOnly =>
search_by_fingerprint(
request,
interactions,
clamp_cursor(cursor, total),
usable_index,
)
Subset(keys) =>
search_by_subset(request, interactions, clamp_cursor(cursor, total), keys)
}
}
///|
/// 判断「没找到」是不是因为**录制已耗尽**。
///
/// 只有 `Sequential` 会耗尽:它按位置消费,游标越界就意味着录制的条数不够用。
/// 环形模式(`Exact` / `FingerprintOnly` / `Subset`)永远会回卷,因此它们返回
/// 「没找到」一定是确实没有对应记录。
///
/// 区分这两种情况的意义在于**修法不同**:耗尽要补录制,没有对应记录要改请求
/// 或换匹配策略。把前者报成「没有匹配记录」会把用户引向错误的方向。
///
/// 空 cassette 不算耗尽:那属于「一条都没录」,报「没有匹配记录」更贴切。
pub fn is_exhausted(policy : MatchPolicy, cursor : Int, total : Int) -> Bool {
match policy {
Sequential => total > 0 && (cursor < 0 || cursor >= total)
_ => false
}
}
///|
/// 把越界游标折回 0。
///
/// 只用于环形查找(`Exact` / `FingerprintOnly` / `Subset`):
/// 这类模式允许在录制耗尽后回卷复用此前的记录。
fn clamp_cursor(cursor : Int, total : Int) -> Int {
if cursor >= 0 && cursor < total {
cursor
} else {
0
}
}
///|
/// 环形遍历下标:第 `offset` 个访问的下标。
fn ring_index(start : Int, offset : Int, total : Int) -> Int {
let raw = start + offset
if raw < total {
raw
} else {
raw - total
}
}
///|
/// 严格匹配:指纹筛选 + 规范文本全等校验。
///
/// 规范文本只在该候选指纹相同时才计算:它是**校验**,不是筛选条件,
/// 对绝大多数不匹配的候选都是白算。
fn search_by_text(
request : @core.Request,
interactions : ArrayView[@core.Interaction],
start : Int,
index : MatchIndex?,
) -> MatchResult? raise @core.CassetteError {
let total = interactions.length()
let target_fingerprint = @fingerprint.fingerprint(request)
let mut offset = 0
while offset < total {
let at = ring_index(start, offset, total)
if recorded_fingerprint(interactions, index, at) == target_fingerprint {
if @fingerprint.canonical_request(interactions[at].request) ==
@fingerprint.canonical_request(request) {
return Some({ index: at, interaction: interactions[at] })
}
raise @core.CassetteError::FingerprintCollision(target_fingerprint)
}
offset = offset + 1
}
None
}
///|
/// 宽松匹配:只比较指纹。
fn search_by_fingerprint(
request : @core.Request,
interactions : ArrayView[@core.Interaction],
start : Int,
index : MatchIndex?,
) -> MatchResult? {
let total = interactions.length()
let target = @fingerprint.fingerprint(request)
let mut offset = 0
while offset < total {
let at = ring_index(start, offset, total)
if recorded_fingerprint(interactions, index, at) == target {
return Some({ index: at, interaction: interactions[at] })
}
offset = offset + 1
}
None
}
///|
/// 子集匹配:只比较请求体顶层的指定字段。
fn search_by_subset(
request : @core.Request,
interactions : ArrayView[@core.Interaction],
start : Int,
keys : Array[String],
) -> MatchResult? {
let total = interactions.length()
let target = subset_text(request, keys)
let mut offset = 0
while offset < total {
let index = ring_index(start, offset, total)
let candidate = interactions[index]
if subset_text(candidate.request, keys) == target {
return Some({ index, interaction: candidate })
}
offset = offset + 1
}
None
}
///|
/// 把请求投影到指定字段后取规范文本。
///
/// 只投影 `body` 的顶层字段;其余层级原样保留,
/// 以免「子集匹配」在嵌套结构上产生难以预期的宽松行为。
///
/// 投影后仍复用 `@fingerprint.canonical_text` 生成文本,因此
/// 「子集匹配」与「精确匹配」共享同一套形状定义与排序规则。
fn subset_text(request : @core.Request, keys : Array[String]) -> String {
let body = match request.body {
Object(fields) => {
let kept : Map[String, Json] = Map([])
for key in keys {
match fields.get(key) {
Some(value) => kept.set(key, value)
None => ()
}
}
Json::object(kept)
}
scalar => scalar
}
@fingerprint.canonical_text(request.provider, request.model, body)
}