Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 137 additions & 6 deletions crates/utopia-server/src/phrase_alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,64 @@ fn consider<'a>(
.collect()
}

/// 第二票要不要投。第二票防的是「选第一个」冒充一致,要两票一致的是**绑定**;第一票
/// 说没有一条对得上的签名,第二票怎么答都绑不上:答没有是「无」,答了一条是「拿不定」。
/// 测量库一轮 864 条签名里第一票说没有的 317 条,没有一条最后绑上(bench README,
/// 2026-09-28),这一票省下。第一票没答到的也不投:少一票本来就不下结论。
/// 补问 marks 的照旧投两票:它要两票说同一个值
fn second_vote_is_due(first: &Vote, first_answered: bool, marks_only: bool) -> bool {
marks_only || (first_answered && first.is_some())
}

/// 类别词规则的宾语是从类别词自己的字里读出来的(「british film」读出英国),只有中心词
/// 的类别词(「state」「ship」)没有可读的字:一轮 159 个类别词里 127 个是单个词,提出
/// 的 8 条规则没有一条读得出宾语(bench README,2026-09-28)。这种不问。
///
/// 只认得出用空格分词的写法:一串 ASCII 字母是一个词。带连字符、数字的(「1968」、
/// 「british-governed」)和不分词的文字(「英国电影」)看不出有没有修饰语,照旧问
fn has_modifier(kind_word: &str) -> bool {
!kind_word.trim().chars().all(|c| c.is_ascii_alphabetic())
}

/// 把签名分成批,候选相近的放在一起。返回每批里签名的下标。
///
/// 属性定义表在一批里只写一遍,占一次调用输入的将近一半:按到来的次序每十二条切一批时,
/// 同批的签名互不相干(一条讲出生地、一条讲导演、一条讲所属球队),各自十条候选几乎不
/// 重叠,合起来就是本体的一半(测量库上九十六条属性里约五十条)。这里只改排法:每条签名
/// 看到的候选一条不少,判断一字不变。
///
/// 贪心:每批从还没排的第一条起,每次加进让这一批的候选并集长得最少的那一条,并列时取
/// 靠前的——结果只由输入决定,同样的签名每次排成同样的批。没有候选的签名不问模型,
/// 排在最后,不占有候选的批里的位置
fn batch_by_candidates<K: Copy + Eq + std::hash::Hash>(
sets: &[Vec<K>],
size: usize,
) -> Vec<Vec<usize>> {
let size = size.max(1);
let mut left: Vec<usize> = (0..sets.len()).filter(|i| !sets[*i].is_empty()).collect();
let empty: Vec<usize> = (0..sets.len()).filter(|i| sets[*i].is_empty()).collect();
let mut out: Vec<Vec<usize>> = Vec::new();
while !left.is_empty() {
let first = left.remove(0);
let mut batch = vec![first];
let mut union: HashSet<K> = sets[first].iter().copied().collect();
while batch.len() < size && !left.is_empty() {
let (at, _) = left
.iter()
.enumerate()
.map(|(at, i)| (at, sets[*i].iter().filter(|k| !union.contains(k)).count()))
.min_by_key(|(at, added)| (*added, *at))
.expect("left is not empty");
let i = left.remove(at);
union.extend(sets[i].iter().copied());
batch.push(i);
}
out.push(batch);
}
out.extend(empty.chunks(size).map(<[usize]>::to_vec));
out
}

/// 日志里放得下的一段回复:空白折成一个空格,最多这么多字符。
const SNIPPET_CHARS: usize = 240;

Expand Down Expand Up @@ -538,8 +596,26 @@ async fn align_phrases_locked(
);
// 先把每批的 future 造出来再排队:直接在 map 里返回 async 块会让借用的生命周期
// 满足不了 tokio::spawn 要的 Send
let futures: Vec<_> = todo
.chunks(BATCH)
// 候选相近的签名排进同一批(见 `batch_by_candidates`):属性表一批只写一遍,同批的
// 签名候选重叠得越多,这张表越短
let sets: Vec<Vec<Uuid>> = todo
.iter()
.map(|s| {
let fitting = &considered[&s.key()].0;
if fitting.len() > CANDIDATE_LIMIT {
Vec::new()
} else {
fitting.iter().map(|p| p.id).collect()
}
})
.collect();
let batches: Vec<Vec<&PhraseSignature>> = batch_by_candidates(&sets, BATCH)
.into_iter()
.map(|group| group.into_iter().map(|i| todo[i]).collect())
.collect();
let futures: Vec<_> = batches
.iter()
.map(|batch| batch.as_slice())
.map(|batch| async move {
let (mut bound, mut none, mut undecided, mut skipped, mut failed, mut unanswered) =
(0usize, 0usize, 0usize, 0usize, 0usize, 0usize);
Expand All @@ -566,6 +642,7 @@ async fn align_phrases_locked(
.iter()
.enumerate()
.filter(|(i, _)| !cands[*i].is_empty())
.filter(|(i, s)| pass == 0 || second_vote_is_due(&votes[*i].0, answered[*i].0, marks_only.contains_key(&s.key())))
.map(|(i, s)| {
let mut list: Vec<&RelationTypeView> = cands[i].clone();
if pass == 1 {
Expand Down Expand Up @@ -753,7 +830,9 @@ async fn align_phrases_locked(
}
let (a, b) = &votes[i];
let (ans_a, ans_b) = answered[i];
if !ans_a || !ans_b {
// 第一票说没有的不投第二票(见 `second_vote_is_due`):结论就是没有
let asked_twice = second_vote_is_due(a, ans_a, false);
if !ans_a || (asked_twice && !ans_b) {
// 有一票没答到:不下结论,下次再问
unanswered += 1;
continue;
Expand All @@ -766,7 +845,11 @@ async fn align_phrases_locked(
})
.unwrap_or(serde_json::Value::Null)
};
let record = serde_json::json!({ "first": show(a), "second": show(b) });
let record = if asked_twice {
serde_json::json!({ "first": show(a), "second": show(b) })
} else {
serde_json::json!({ "first": null, "reason": "first_vote_none" })
};
// 两票选的属性与方向(或都说没有)是否一致
let agree = match (a, b) {
(Some((ka, da, _)), Some((kb, db, _))) => ka == kb && da == db,
Expand Down Expand Up @@ -946,11 +1029,11 @@ async fn align_phrases_locked(
// 类别词的候选也开短名单:词加例名嵌入后取最近的属性;没有向量时看全部
let fresh: Vec<&utopia_store::type_bindings::KindWordSignature> = kind_words
.iter()
.filter(|k| !asked_kind.contains(k.kind_word.as_str()))
.filter(|k| !asked_kind.contains(k.kind_word.as_str()) && has_modifier(&k.kind_word))
.collect();
let kind_short = shortlist_kind_words(state, settings, kb_id, &fresh).await?;
for (k, basis) in kind_words.iter().zip(kind_basis.iter()) {
if asked_kind.contains(k.kind_word.as_str()) {
if asked_kind.contains(k.kind_word.as_str()) || !has_modifier(&k.kind_word) {
continue;
}
let mut candidates: Vec<&RelationTypeView> = props
Expand Down Expand Up @@ -1071,6 +1154,54 @@ mod lifecycle_tests;

#[cfg(test)]
mod tests {
#[test]
fn a_kind_word_that_is_only_a_head_word_has_nothing_to_read() {
for bare in ["state", "ship", " settlement "] {
assert!(!has_modifier(bare), "{bare}");
}
for readable in [
"british film",
"1968",
"british-governed",
"英国电影",
"wine area",
] {
assert!(has_modifier(readable), "{readable}");
}
}

#[test]
fn signatures_with_the_same_candidates_share_a_batch() {
// 六条签名、三种候选集,交错着来;每批两条
let sets: Vec<Vec<u32>> = vec![
vec![1, 2],
vec![7, 8],
vec![],
vec![2, 1],
vec![8, 9],
vec![1, 2, 3],
];
let batches = batch_by_candidates(&sets, 2);
// 每条签名恰好出现一次
let mut all: Vec<usize> = batches.iter().flatten().copied().collect();
all.sort_unstable();
assert_eq!(all, vec![0, 1, 2, 3, 4, 5]);
// 候选一样的排在一起,没有候选的在最后
assert_eq!(batches, vec![vec![0, 3], vec![1, 4], vec![5], vec![2]]);
// 同样的输入排成同样的批
assert_eq!(batch_by_candidates(&sets, 2), batches);
// 一批的候选并集比按次序切的小
let union = |b: &Vec<usize>| {
b.iter()
.flat_map(|i| sets[*i].iter())
.collect::<HashSet<_>>()
.len()
};
let in_order: usize = [vec![0, 1], vec![2, 3], vec![4, 5]].iter().map(union).sum();
let grouped: usize = batches.iter().map(union).sum();
assert!(grouped < in_order, "{grouped} < {in_order}");
}

#[test]
fn a_shortlist_keeps_label_matches_and_fills_by_distance_within_the_fitting_set() {
let ids: Vec<Uuid> = (0..6).map(|_| Uuid::now_v7()).collect();
Expand Down
37 changes: 32 additions & 5 deletions crates/utopia-server/src/phrase_alignment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ fn bound() -> Vec<Value> {
]
}
fn none() -> Vec<Value> {
vec![vote(None, None), vote(None, None), nothing_implied()]
vec![vote(None, None)]
}

/// 类别词对齐还排着时短语对齐不动手:两端的类还没定,判了也是重判。它给自己排一份半分钟
Expand Down Expand Up @@ -591,8 +591,8 @@ async fn an_edit_during_the_model_request_leaves_the_decision_stale() -> anyhow:
f.run().await?;
assert_eq!(
f.requests().len(),
4,
"two votes, then two votes again (a none is not asked for rules): the basis differs, not the clock"
3,
"one vote that says none, then two votes (a none is not asked for rules): the basis differs, not the clock"
);
assert_eq!(f.binding().await?.status, "bound");
anyhow::Ok(())
Expand Down Expand Up @@ -657,6 +657,29 @@ async fn a_person_decision_made_during_the_request_is_not_overwritten() -> anyho

/// 类别词那边 bench 里「12 篇文档一个词都没绑上」的回复形状搬到短语上:DeepSeek-V3.2
/// 把整段答成按 id 作键的对象,第二票把 `b` 写成对象、值写成 `{"key","direction"}`。
/// 第一票说没有,结论就是没有:第二票不投,也不问规则
#[tokio::test]
async fn a_first_vote_that_says_none_is_not_followed_by_a_second() -> anyhow::Result<()> {
let Some(f) = Fx::new().await? else {
return Ok(());
};
let run = async {
f.script(vec![
vote(None, None),
vote(Some("based_in"), Some("forward")),
]);
f.run().await?;
assert_eq!(f.requests().len(), 1, "one vote");
assert_eq!(f.binding().await?.status, "none");
assert_eq!(f.reason().await?.as_deref(), Some("first_vote_none"));
assert_eq!(f.typed().await?, 0);
anyhow::Ok(())
}
.await;
f.cleanup().await?;
run
}

/// 两票都得读出来,签名才绑得上
#[tokio::test]
async fn an_id_keyed_reply_still_binds_the_phrase() -> anyhow::Result<()> {
Expand Down Expand Up @@ -702,7 +725,11 @@ async fn an_unreadable_reply_is_asked_again_a_bounded_number_of_times() -> anyho
json!({"answer": "based_in", "direction": "forward"}),
]);
f.run().await?;
assert_eq!(f.requests().len(), 2, "two votes, neither readable");
assert_eq!(
f.requests().len(),
1,
"the first vote is unreadable, the second is not cast"
);
assert!(
phrase_bindings::bindings(&f.pool, f.kb).await?.is_empty(),
"an unreadable reply writes no decision"
Expand All @@ -722,7 +749,7 @@ async fn an_unreadable_reply_is_asked_again_a_bounded_number_of_times() -> anyho
f.clear_jobs().await?;
// 已经是最后一次自己排的:不再排
align_phrases_reasking(&f.state, f.kb, MAX_REASK).await?;
assert_eq!(f.requests().len(), 4, "the last allowed round still asks");
assert_eq!(f.requests().len(), 2, "the last allowed round still asks");
let after: i64 = sqlx::query_scalar(
"SELECT count(*) FROM jobs WHERE kind='align_phrases' AND payload->>'kb_id'=$1",
)
Expand Down
34 changes: 34 additions & 0 deletions scripts/bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -830,3 +830,37 @@ bound / none / undecided。0044 的门槛:裁判精度不低于完整原型的
新签名、每条约 1.4 千 token,这就是它的地板。0044 的 token 门槛在 Re-DocRED 上要么换口径(每条新签名
的成本),要么换同领域语料再量。


### 第六次真跑:对齐少问一些(2026-09-28,dev @ b036f45,claude-haiku-4-5 经 apexin.net,bge-m3,20 篇)

20 篇按调用分(`typed.mjs` 的 token 表):短语对齐的两票占 38%,抽取 20%,提规则 14%,类别词对齐 9%。
短语对齐一次调用的输入约 4600 token,其中属性表将近一半——按到来次序切批时,同批签名的候选几乎不重叠。
三处改动:

1. **按候选分批**:候选相近的签名排进同一批。只改排法,每条签名看到的候选不变。
2. **第一票说没有的不投第二票**:第二票防的是「选第一个」冒充一致;第一票说没有,第二票怎么答都绑不上。
3. **只有中心词的类别词不问规则**:类别词规则的宾语从类别词自己的字里读(「british film」读出英国)。
基线一轮问了 159 个类别词,127 个是单个词,提出的 8 条规则(「state」→ 国家)没有一条读得出宾语。

两轮完整的 20 篇之间抽取自己就不一样(签名 229 对 210,只有 137 条相同),整轮的数字分不出对齐的
变化。所以**在同一个库的同一批陈述上**把绑定清掉重判,210 条签名,每种构建各判一遍或两遍:

| | 基线 | 基线 再一遍 | 分批 | 分批 再一遍 | 三处都改 |
|---|---|---|---|---|---|
| 短语对齐调用 | 46 | 47 | 36 | 40 | 32 |
| 输入 token | 190572 | 199086 | 128338 | 143900 | 103412 |
| 输出 token | 46537 | 45422 | 35809 | 36644 | 29703 |
| 合计 | 237109 | 244508 | 164147 | 180544 | 133115 |
| 签名 bound / none / undecided | 53 / 110 / 47 | 66 / 108 / 36 | 69 / 108 / 33 | 67 / 109 / 34 | 63 / 119 / 28 |
| 类型化事实 | 82 | 87 | 99 | 92 | 84 |

读数:
- 分批省短语对齐的三成(24.1 万 → 17.2 万),加上条件第二票省四成五(→ 13.3 万)。
- **模型自己的波动比改动大**:同一个构建、同一批签名、温度 0,两遍判定完全相同的只有 166 / 210(基线)
和 163 / 210(分批)。基线对分批是 146 到 159。分批让判定多变了约十条,方向两边都有(绑上的数和
类型化行数没有变少);20 篇整轮的裁判精度 79.3% 对 78.8%。
- 条件第二票的代价:它记成「无」的 98 条,在另外四遍里 81 到 86 条本来也是「无」,8 到 12 条是
「拿不定」(第二票答了一条属性),4 到 5 条绑上了(那一遍第一票答的不一样,是波动)。「拿不定」的那
十条上下不再进人的队列,`votes.reason = "first_vote_none"` 查得到。
- 20 篇整轮(只有分批):合计 82.1 万 → 62.6 万,每篇 4.1 万 → 3.1 万;其中约一半来自分批,其余是
这一轮抽出的签名少、重问少。