diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index f596f7dea..337666ad7 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -309,7 +309,7 @@ pub fn build_messages( units and all. **A stated figure left out is the loss that costs most**: the reader \ came for those numbers, and no later step can recover one that was never written \ down.\n\ - 8c. A listed relation followed by {{…}} can carry those **qualifiers on the edge**: when the same sentence gives both the other entity and a figure for it — an amount, a stake, a price, a share count — write the relation with its \"object\" and put the figure in \"qualifiers\" keyed exactly as listed, **as written in the text, currency and all** (\"€30 million\", \"15亿元人民币\", never a bare number): {{\"subject\":\"Vega Capital\",\"predicate\":\"invested_in\",\"object\":\"Northwind\", \"qualifiers\":{{\"amount\":\"$5 billion\"}},…}}. Never invent a key that is not listed for that relation, and never drop the figure to keep the edge — a relation without its amount is half the sentence. + 8c. A listed relation followed by {{…}} can carry those **qualifiers on the edge**: when the same sentence gives both the other entity and a figure for it — an amount, a stake, a price, a share count — write the relation with its \"object\" and put the figure in \"qualifiers\" keyed exactly as listed, **as written in the text, currency and all** (\"€30 million\", \"15亿元人民币\", never a bare number): {{\"subject\":\"Vega Capital\",\"predicate\":\"invested_in\",\"object\":\"Northwind\", \"qualifiers\":{{\"amount\":\"$5 billion\"}},…}}. Never invent a key that is not listed for that relation, and never drop the figure to keep the edge — a relation without its amount is half the sentence. A relation you name after the text (rule 8) carries its figure the same way — keyed by the listed attribute that fits it, or by the plainest word for it (\"amount\", \"stake\", \"price\") when none does. 8b. A **listed** relation also takes \"value\" when what the text gives is a \ string rather than another entity — a job title, a designation, a ticker, a \ model number. Never invent an entity for a string. And when the text introduces \ diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs index d6bcba604..2244e1771 100644 --- a/crates/utopia-server/src/extraction.rs +++ b/crates/utopia-server/src/extraction.rs @@ -1492,8 +1492,10 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: continue; }; let mut object_value = serde_json::json!({ "value": normalized }); - if let Some(u) = attr.unit.as_deref().filter(|u| !u.is_empty()) { - // 单位随事实落笔:类型上的单位以后改了,旧值仍按记录时的单位读 + // 单位随事实落笔:类型上的单位以后改了,旧值仍按记录时的单位读。 + // 记哪个单位照 `unit_for`——从前这里无条件盖上声明的单位,实测 + //「提供 500 兆瓦的风电」被模型记成金额,再盖上 ¥ 就成了 500 块钱 + if let Some(u) = unit_for(&raw, datatype, None, attr.unit.as_deref()) { object_value["unit"] = serde_json::json!(u); } if await_nod { @@ -2304,10 +2306,16 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: 同一条边再听到一次带了金额的,是同一条边补上金额。 值照 datatype 换算,单位另记一格(与 object_value 同形); key 不在声明里、换不动、与已记的不一致——三种都进丢弃表,不静默 */ - if let (Some(pid), Some(quals)) = (predicate_id, f.qualifiers.as_ref()) { + /* 谓词未知(0010,说法在证据上)时属性照写:值落在事实上,声明等 + 关系被采纳时在 `adopt` 里补。不写的话,`invested_in` 在 schema.org + 库里是未知说法,一句话里的 $1.5 billion 就没有地方放——召回台上 + `oh-invest` 那一条正是这么丢的 */ + if let Some(quals) = f.qualifiers.as_ref() { + let pid = predicate_id; // 克隆出这一组引用:下面撞上已有属性时要往 qualifier_defs 里追加声明 - let defs: Vec<&utopia_core::models::RelationType> = - qualifier_defs.get(&pid).cloned().unwrap_or_default(); + let defs: Vec<&utopia_core::models::RelationType> = pid + .and_then(|p| qualifier_defs.get(&p).cloned()) + .unwrap_or_default(); /* 模型常把币种单独写成一个键(`"amount": "1500000000", "currency": "CNY"`), 而不是写进数额里。那不是一个属性,是数额的单位——先把它拿出来, 数值属性解不出单位时用它,别让它作为未知 key 进丢弃表 */ @@ -2343,9 +2351,16 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: 所以跟自动扩本体走同一个开关 */ let adopted = match declared { Some(d) => Some(d), + /* 谓词还未知(0010,说法在证据上):没有关系可声明,值绑到库里 + 已有的属性定义、先落在事实上,采纳时 `adopt` 再补声明。这一步 + 不动本体,所以不看自动扩本体的开关 */ + None if pid.is_none() => { + attr_by_key.get(&key.trim().to_lowercase()).copied() + } None if kb.auto_extend_ontology => { match attr_by_key.get(&key.trim().to_lowercase()).copied() { Some(attr) => { + let pid = pid.expect("checked above"); match utopia_store::ontology::add_relation_qualifier( &state.pool, doc.kb_id, @@ -2377,15 +2392,50 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: } }; let Some(def) = adopted else { - drop_signal( - state, + /* **绑不上属性定义的数也不丢。** 库里没有这个属性(schema.org 里 + `amount` 是关系不是属性)、或本体冻着不让扩——从前这里进丢弃表, + 数就只剩丢弃表里的一个样例。现在照 8a 的样子落成主语上的一条 + 字面值事实:值按原文、单位另记一格、原词 `关系.键` 进证据的 + proposed_predicate,缺的定义记进 ontology_misses(0010 的样子), + 采纳时人来决定它归哪儿。图里有它、有证据、能查到 */ + let wording = format!("{}.{}", f.predicate.trim(), key.trim()); + let unit = raw + .as_str() + .and_then(utopia_extract::parse_leading_quantity) + .and_then(|(_, u)| u) + .or_else(|| sibling_currency.map(str::to_string)); + let mut literal = serde_json::json!({ "value": raw }); + if let Some(u) = unit { + literal["unit"] = serde_json::Value::String(u); + } + let _ = utopia_store::ontology::record_miss( + &state.pool, doc.kb_id, - document_id, - utopia_store::extraction_drops::reason::QUALIFIER_UNKNOWN, - &format!("{}.{}", f.predicate, key), - Some(&raw.to_string()), + "attribute_type", + &wording, + Some(&format!("{} → {raw}", f.subject.trim())), ) .await; + let (literal_id, _) = utopia_store::graph::insert_value_fact( + &state.pool, + doc.kb_id, + subject_id, + None, + &literal, + validity, + confidence, + ) + .await?; + touched_facts.push(literal_id); + utopia_store::graph::add_evidence( + &state.pool, + literal_id, + chunk.id, + f.quote.as_deref(), + Some(wording.as_str()), + ) + .await?; + tracing::debug!(kb_id = %doc.kb_id, wording = %wording, "边上的属性绑不上定义,落成主语上的字面值"); continue; }; let dt = def.datatype.as_deref().unwrap_or("text"); @@ -2402,28 +2452,7 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: continue; }; let mut value = serde_json::json!({ "value": normalized }); - /* 单位:原文里认得出的用原文的(€、¥、%),原文里**没有任何单位记号** - 才落回属性声明的缺省。原文带着一个认不出的单位("francs")时 - **不能**拿缺省顶上——实测 `EUR 30 million` 被存成了 `$`, - `15亿元人民币` 也是;币种写错比不写更糟 */ - let parsed = raw - .as_str() - .and_then(utopia_extract::parse_leading_quantity); - let raw_has_unit_token = raw.as_str().is_some_and(|t| { - t.chars().any(|c| { - c.is_alphabetic() - || matches!(c, '$' | '€' | '£' | '¥' | '₩' | '₹' | '%') - }) - }); - let unit = match parsed.and_then(|(_, u)| u) { - Some(u) => Some(u), - None => match sibling_currency { - Some(c) if dt == "number" => Some(c.to_string()), - _ if raw_has_unit_token => None, - _ => def.unit.clone().filter(|u| !u.is_empty()), - }, - }; - if let Some(u) = unit { + if let Some(u) = unit_for(raw, dt, sibling_currency, def.unit.as_deref()) { value["unit"] = serde_json::Value::String(u); } let write = utopia_store::graph::upsert_fact_qualifier( @@ -2938,6 +2967,129 @@ async fn chunk_lists( ))) } +/// 一条值该记什么单位(#600「单位是读出来的,不是猜的」,实体上的属性与边上的属性同一条规矩)。 +/// +/// 原文里认得出的用原文的(€、¥、%、"EUR 30 million" 的 €);模型把币种单独写成 +/// 一个键时用那个;原文里写着声明的那个单位("4300 人" 对 "人")也算读出来的。 +/// 原文带着一个认不出的单位记号("500 兆瓦"、"francs")时**不能**拿声明的缺省顶上—— +/// 实测 `EUR 30 million` 被存成了 `$`,`500 兆瓦` 被存成了 500 块钱;单位写错比不写更糟。 +/// 只有原文完全没有单位记号(一个光秃秃的数)才落回声明的缺省。 +fn unit_for( + raw: &serde_json::Value, + datatype: &str, + sibling_currency: Option<&str>, + declared: Option<&str>, +) -> Option { + let declared = declared.map(str::trim).filter(|u| !u.is_empty()); + let text = raw.as_str(); + if let Some(u) = text + .and_then(utopia_extract::parse_leading_quantity) + .and_then(|(_, u)| u) + { + return Some(u); + } + if let (Some(c), "number") = (sibling_currency, datatype) { + return Some(c.to_string()); + } + if let (Some(t), Some(d)) = (text, declared) { + if t.contains(d) { + return Some(d.to_string()); + } + } + let has_unit_token = text.is_some_and(|t| { + t.chars() + .any(|c| c.is_alphabetic() || matches!(c, '$' | '€' | '£' | '¥' | '₩' | '₹' | '%')) + }); + if has_unit_token { + None + } else { + declared.map(str::to_string) + } +} + +#[cfg(test)] +mod unit_for_tests { + use super::unit_for; + use serde_json::{json, Value}; + + fn s(t: &str) -> Value { + Value::String(t.to_string()) + } + + #[test] + fn a_unit_is_read_from_the_text_before_anything_else() { + assert_eq!( + unit_for(&s("EUR 30 million"), "number", None, Some("$")).as_deref(), + Some("€") + ); + assert_eq!( + unit_for(&s("8.6亿元"), "number", None, Some("$")).as_deref(), + Some("¥") + ); + assert_eq!( + unit_for(&s("12%"), "number", None, Some("¥")).as_deref(), + Some("%") + ); + assert_eq!( + unit_for(&s("$5 billion"), "number", None, None).as_deref(), + Some("$") + ); + } + + #[test] + fn a_sibling_currency_is_the_unit_of_a_bare_number() { + assert_eq!( + unit_for(&s("1500000000"), "number", Some("CNY"), Some("$")).as_deref(), + Some("CNY") + ); + // 文本型属性没有币种可言 + assert_eq!(unit_for(&s("B 轮"), "text", Some("CNY"), None), None); + } + + #[test] + fn the_declared_unit_written_in_the_text_counts_as_read() { + assert_eq!( + unit_for(&s("4300 人"), "number", None, Some("人")).as_deref(), + Some("人") + ); + assert_eq!( + unit_for(&s("三年"), "text", None, Some("年")).as_deref(), + Some("年") + ); + } + + #[test] + fn an_unknown_unit_token_is_never_overwritten_by_the_default() { + // 宽松扫描把尾巴上的记号当单位读出来:记的是原文的单位,不是声明的 ¥ + assert_eq!( + unit_for(&s("500 兆瓦"), "number", None, Some("¥")).as_deref(), + Some("兆瓦") + ); + assert_eq!( + unit_for(&s("30 million francs"), "number", None, Some("$")).as_deref(), + Some("francs") + ); + // 扫描读不出、原文却明明带着字:也不拿缺省顶上 + assert_eq!( + unit_for(&s("about five hundred"), "number", None, Some("$")), + None + ); + } + + #[test] + fn a_bare_figure_takes_the_declared_default() { + assert_eq!( + unit_for(&s("4300"), "number", None, Some("人")).as_deref(), + Some("人") + ); + assert_eq!( + unit_for(&json!(4300), "number", None, Some("人")).as_deref(), + Some("人") + ); + assert_eq!(unit_for(&s("4300"), "number", None, Some("")), None); + } +} + #[cfg(test)] mod name_tests { use super::{clause_suspect, is_entity_name}; diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index 266f381ad..3561cc76e 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -1994,6 +1994,32 @@ async fn adopt( .bind(old_id) .execute(&mut *tx) .await?; + /* **边上的属性跟着搬**(0037)。谓词还没被采纳时金额就已经落在旧行上—— + 一句「NVIDIA invested $1.5 billion in SB Energy」在 schema.org 库里 + `invested_in` 是未知说法,钱不能等到采纳那天才有地方放。旧行作废、 + 新行接上,属性照证据的样子整体复制;顺手在关系上补声明—— + 这些属性定义本来就在库里,缺的只是关系上的一条声明 */ + sqlx::query( + "INSERT INTO fact_qualifiers (fact_id, qualifier_type_id, value, entity_id) + SELECT $1, qualifier_type_id, value, entity_id + FROM fact_qualifiers WHERE fact_id = $2 + ON CONFLICT DO NOTHING", + ) + .bind(new_id) + .bind(old_id) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO relation_type_qualifiers (relation_type_id, qualifier_type_id) + SELECT $1, q.qualifier_type_id + FROM fact_qualifiers q JOIN relation_types r ON r.id = q.qualifier_type_id + WHERE q.fact_id = $2 AND r.kind = 'attribute' AND r.id <> $1 + ON CONFLICT DO NOTHING", + ) + .bind(predicate_id) + .bind(new_id) + .execute(&mut *tx) + .await?; sqlx::query("UPDATE facts SET invalidated_at = now() WHERE id = $1") .bind(old_id) .execute(&mut *tx) diff --git a/crates/utopia-store/tests/an_amount_outlives_adoption.rs b/crates/utopia-store/tests/an_amount_outlives_adoption.rs new file mode 100644 index 000000000..4a2f976d6 --- /dev/null +++ b/crates/utopia-store/tests/an_amount_outlives_adoption.rs @@ -0,0 +1,166 @@ +//! 谓词还没被采纳时,金额已经落在事实上;采纳把属性搬到新行、并在关系上补声明(0037)。 +//! +//! 一句「NVIDIA invested $1.5 billion in SB Energy」在 schema.org 库里 `invested_in` +//! 是未知说法,钱不能等到采纳那天才有地方放。 + +use sqlx::PgPool; +use uuid::Uuid; + +#[tokio::test] +async fn a_qualifier_written_before_adoption_survives_it() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let tag = Uuid::now_v7(); + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, $2)") + .bind(org) + .bind(format!("outlive-{tag}")) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, $3)") + .bind(ws) + .bind(org) + .bind(format!("outlive-{tag}")) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, $3)") + .bind(kb) + .bind(ws) + .bind(format!("outlive-{tag}")) + .execute(&pool) + .await?; + let class = Uuid::now_v7(); + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'org', 'Org')") + .bind(class) + .bind(kb) + .execute(&pool) + .await?; + let amount = utopia_store::ontology::create_relation_type( + &pool, + kb, + "amount", + "amount", + "state", + Default::default(), + "", + "attribute", + &[class], + &[], + Some("number"), + Some("$"), + ) + .await?; + // 关系此刻还不存在于本体里——先建好但不声明任何属性,模拟"采纳"那一刻 + let invested = utopia_store::ontology::create_relation_type( + &pool, + kb, + "invested_in", + "invested in", + "event", + Default::default(), + "", + "relation", + &[], + &[], + None, + None, + ) + .await?; + let (a, b) = (Uuid::now_v7(), Uuid::now_v7()); + for (id, name) in [(a, "NVIDIA"), (b, "SB Energy")] { + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(kb) + .bind(class) + .bind(format!("{name}-{tag}")) + .execute(&pool) + .await?; + } + let (doc, chunk, fact) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO documents (id, kb_id, filename, sha256) VALUES ($1, $2, $3, $4)") + .bind(doc) + .bind(kb) + .bind(format!("{tag}.txt")) + .bind(tag.to_string()) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1, $2, $3, 0, 'x')", + ) + .bind(chunk) + .bind(kb) + .bind(doc) + .execute(&pool) + .await?; + // 谓词为空的事实 + 说法在证据上 + 金额已经在边上 + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, NULL, $4, 0.9)", + ) + .bind(fact) + .bind(kb) + .bind(a) + .bind(b) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO fact_evidence (fact_id, chunk_id, document_id, proposed_predicate) + VALUES ($1, $2, $3, 'invested_in')", + ) + .bind(fact) + .bind(chunk) + .bind(doc) + .execute(&pool) + .await?; + let value = serde_json::json!({ "value": 1500000000.0, "unit": "$" }); + utopia_store::graph::upsert_fact_qualifier(&pool, fact, amount, &value).await?; + + // 采纳:旧行作废、新行接上谓词 + let adopted = utopia_store::graph::adopt_proposed_predicates( + &pool, + kb, + invested, + &["invested_in".to_string()], + false, + ) + .await?; + assert_eq!(adopted.moved, 1); + + // 活着的那一行带着金额 + let live: Vec<(Uuid,)> = sqlx::query_as( + "SELECT id FROM facts WHERE kb_id = $1 AND predicate_id = $2 AND invalidated_at IS NULL", + ) + .bind(kb) + .bind(invested) + .fetch_all(&pool) + .await?; + assert_eq!(live.len(), 1, "采纳后正好一条活着的边"); + let quals = utopia_store::graph::fact_qualifiers_for(&pool, &[live[0].0]).await?; + let q = &quals[&live[0].0]; + assert_eq!(q.len(), 1); + assert_eq!(q[0].key, "amount"); + assert_eq!(q[0].value, Some(value), "金额跟着搬到了新行"); + + // 关系上补了声明 + let declared = utopia_store::graph::relation_types(&pool, kb) + .await? + .into_iter() + .find(|r| r.id == invested) + .map(|r| r.qualifiers) + .unwrap_or_default(); + assert_eq!(declared, vec![amount], "采纳时按边上已有的属性补了声明"); + + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(kb) + .execute(&pool) + .await?; + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + Ok(()) +} diff --git a/docs/decisions/0037-a-relation-carries-its-own-attributes.md b/docs/decisions/0037-a-relation-carries-its-own-attributes.md index 90232ed65..a27bf89bb 100644 --- a/docs/decisions/0037-a-relation-carries-its-own-attributes.md +++ b/docs/decisions/0037-a-relation-carries-its-own-attributes.md @@ -114,7 +114,9 @@ rules corpus declared and undeclared, a Chinese corpus declared and undeclared): writes the currency as a sibling key (`"currency": "CNY"`). The prompt now asks for the figure as written, the scanner reads ISO codes, currency words and CJK magnitudes (万, 亿), a sibling `currency` key becomes the unit, and the attribute's default unit is used only - when the text carries no unit token at all — a wrong currency is worse than none. + when the text carries no unit token at all — a wrong currency is worse than none. The same + rule (`unit_for`) now governs an attribute written on an entity, which used to stamp the + declared unit unconditionally: `500 兆瓦` filed under 金额 came out as ¥500. - **Two mentions of one edge in parallel can both insert.** The dedup in `insert_fact_inner` is a read-then-write with no unique index behind it; two documents describing the same `(subject, predicate, object, moment)` extracted at the same time produced two rows with @@ -126,10 +128,13 @@ rules corpus declared and undeclared, a Chinese corpus declared and undeclared): ## Open questions -- Where does the amount go when the relation is not adopted yet (`predicate_id` is null, - wording on the evidence)? This cut drops it with a reason. Keeping it keyed by wording - until adoption would preserve the figure; the bootstrap would then have to propose the - qualifier along with the relation. +- ~~Where does the amount go when the relation is not adopted yet?~~ Answered: a qualifier + on a fact whose predicate is unknown binds to an attribute the base already defines (no + ontology change, so no switch), and adoption (`adopt`) carries the qualifiers onto the new + row and declares them on the relation. A qualifier that binds to nothing — the base has no + such attribute, or the ontology is frozen — is written as a literal fact on the subject, + worded `relation.key` on its evidence and recorded in `ontology_misses`, the shape rule 8a + gives an unlisted figure. Nothing about an edge is dropped for want of a definition. - The canvas. An edge label with the amount is a rendering change and belongs with the parallel-edge work; the timeline reading an event as a point is [0031](0031-an-event-holds-at-the-moment-it-names.md)'s UI cut. diff --git a/scripts/bench/recall.mjs b/scripts/bench/recall.mjs index c7c32817b..7cffe2a8c 100644 --- a/scripts/bench/recall.mjs +++ b/scripts/bench/recall.mjs @@ -86,7 +86,10 @@ function score() { coalesce(s.canonical_name,''), coalesce(rt.key, coalesce(fe.proposed_predicate,'')), coalesce(o.canonical_name, f.object_value #>> '{}', f.object_value::text, ''), - coalesce(to_char(f.valid_from,'YYYY-MM-DD'),'')) + coalesce(to_char(f.valid_from,'YYYY-MM-DD'),''), + coalesce((SELECT string_agg(q.key || '=' || coalesce(fq.value #>> '{value}', '') || ' ' || coalesce(fq.value #>> '{unit}', ''), ' ') + FROM fact_qualifiers fq JOIN relation_types q ON q.id = fq.qualifier_type_id + WHERE fq.fact_id = f.id), '')) FROM facts f JOIN fact_evidence fe ON fe.fact_id = f.id JOIN documents d ON d.id = fe.document_id @@ -97,8 +100,9 @@ function score() { .split("\n") .filter(Boolean) .map((l) => { - const [file, subj, pred, obj, from] = l.split(""); - return { file, subj, pred, obj, from }; + // 边上的属性(0037)也在这一行里:金额挂在边上时,值不在 object 那一格 + const [file, subj, pred, obj, from, quals] = l.split(""); + return { file, subj, pred, obj, from, quals }; }); const byDoc = new Map(); @@ -122,7 +126,7 @@ function score() { let ok = false; if (t.kind === "value") { ok = facts.some((f) => { - const raw = `${f.subj} | ${f.pred} | ${f.obj} | ${f.from}`; + const raw = `${f.subj} | ${f.pred} | ${f.obj} | ${f.from} | ${f.quals}`; return t.value_any.some((v) => norm(raw).includes(norm(v)) || squash(raw).includes(squash(v))); }); } else if (t.kind === "edge") {