From 92b6de7690421984978711444ff90ffd941c692b Mon Sep 17 00:00:00 2001 From: wangzifei Date: Mon, 21 Sep 2026 20:35:41 +0000 Subject: [PATCH 1/2] Let business rules join entities and conclude relations Signed-off-by: wangzifei --- crates/utopia-cli/src/main.rs | 2 +- crates/utopia-reason/src/derive.rs | 20 +- crates/utopia-reason/src/rules.rs | 437 ++++++++++++++++-- crates/utopia-server/src/api/mcp_tests.rs | 8 + crates/utopia-server/src/api/rule_routes.rs | 6 + crates/utopia-server/src/api/tools.rs | 29 +- crates/utopia-store/src/business_rules.rs | 284 ++++++++---- crates/utopia-store/src/reasoning.rs | 304 ++++++++++-- .../tests/a_rule_concludes_a_relation.rs | 285 ++++++++++++ .../a_rule_computes_what_it_concludes.rs | 4 + .../tests/store/a_rule_concludes_a_type.rs | 5 + .../0047-a-rule-may-conclude-a-relation.md | 2 +- docs/decisions/README.md | 2 +- migrations/0071_a_rule_joins_two_entities.sql | 58 +++ web/src/api.ts | 19 +- web/src/i18n/en.ts | 5 + web/src/i18n/zh.ts | 5 + web/src/pages/Ontology.tsx | 1 + web/src/pages/RulesPanel.tsx | 139 ++++-- 19 files changed, 1425 insertions(+), 190 deletions(-) create mode 100644 crates/utopia-store/tests/a_rule_concludes_a_relation.rs create mode 100644 migrations/0071_a_rule_joins_two_entities.sql diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index 0d6923745..f844b77ab 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -82,7 +82,7 @@ struct ManifestDataDir { /// not a side effect of a code change. // 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` // 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 -const CURRENT_SCHEMA_VERSION: u32 = 74; +const CURRENT_SCHEMA_VERSION: u32 = 75; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-reason/src/derive.rs b/crates/utopia-reason/src/derive.rs index 14ee14c0e..ea473e43e 100644 --- a/crates/utopia-reason/src/derive.rs +++ b/crates/utopia-reason/src/derive.rs @@ -99,7 +99,7 @@ pub struct TimedEdge { } /// 交集。`None` 表示无界那一侧。 -pub(crate) fn overlap( +pub fn overlap( a: (Option, Option), b: (Option, Option), ) -> Option<(Option, Option)> { @@ -156,6 +156,18 @@ type Triple = (Uuid, Uuid, Uuid); /// 三条一跳规则(对称/逆/子属性)与传递放在同一轮里,因为它们互为输入—— /// 逆推出来的边可能让某条传递链接得上,反之亦然。 pub fn derive(edges: &[TimedEdge], axioms: &HashMap) -> Derivation { + derive_with_blocked(edges, axioms, &HashSet::new()) +} + +/// Derive while refusing the named conclusions. +/// +/// A relation that lost a contradiction check is still visible as an input for +/// that check, but it cannot become a premise in the next fixed-point round. +pub fn derive_with_blocked( + edges: &[TimedEdge], + axioms: &HashMap, + blocked: &HashSet<(Uuid, Uuid, Uuid)>, +) -> Derivation { let mut out = Derivation::default(); // 断言过的三元组。**派生撞上它就让路**——asserted > derived 是硬性的 @@ -232,6 +244,9 @@ pub fn derive(edges: &[TimedEdge], axioms: &HashMap) -> Derivation hops.push(((sup, subj, obj), Rule::SubProperty)); } for (t, rule) in hops { + if blocked.contains(&t) { + continue; + } if emit( t, pred, @@ -261,6 +276,9 @@ pub fn derive(edges: &[TimedEdge], axioms: &HashMap) -> Derivation if subj == c { continue; } + if blocked.contains(&(pred, subj, c)) { + continue; + } let Some((nf, nt)) = overlap((acc.from, acc.to), (from, to)) else { continue; }; diff --git a/crates/utopia-reason/src/rules.rs b/crates/utopia-reason/src/rules.rs index 747d789e0..062bd251e 100644 --- a/crates/utopia-reason/src/rules.rs +++ b/crates/utopia-reason/src/rules.rs @@ -24,6 +24,39 @@ pub struct AttrFact { pub value: serde_json::Value, } +/// 一条参与规则连接的实体—实体边。 +#[derive(Debug, Clone, PartialEq)] +pub struct RuleEdge { + pub id: Uuid, + pub predicate: Uuid, + pub subject: Uuid, + pub object: Uuid, +} + +/// 连接边的两侧。`X` 是规则的主语;`Y` 是这条边把它连到的另一个实体。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Side { + X, + Y, +} + +impl Side { + pub fn as_str(self) -> &'static str { + match self { + Side::X => "x", + Side::Y => "y", + } + } + + pub fn parse(s: &str) -> Option { + Some(match s { + "x" | "X" => Side::X, + "y" | "Y" => Side::Y, + _ => return None, + }) + } +} + /// 条件的比较方式。与 `attribute_rule_conditions.op` 的 CHECK 一一对应。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Op { @@ -188,6 +221,8 @@ impl Expr { pub struct Condition { /// 同组的条件用「与」连,组与组之间用「或」连(0029)。老规则全是第 0 组 pub group: i32, + /// 这一条件读连接的哪一侧。没有连接的老规则读的永远是 X + pub side: Side, pub predicate: Uuid, pub op: Op, pub operand: Operand, @@ -206,12 +241,16 @@ pub enum Conclusion { predicate: Uuid, value: serde_json::Value, }, + /// 派生关系:从 X 经规则声明的连接指向 Y(0047) + Relation { predicate: Uuid }, } #[derive(Debug, Clone, PartialEq)] pub struct BusinessRule { pub id: Uuid, pub conclusion: Conclusion, + /// 把 X 与 Y 连起来的那条谓词。`None` 就是老的单实体规则 + pub join_predicate: Option, /// 条件。**组内合取、组间析取**(0029):同一组里全部满足才算这一组成立, /// 任何一组成立这条规则就命中。空条件集永不命中——一条没有判据的规则应当 /// 什么都不推,而不是把整个类都归进去 @@ -223,6 +262,8 @@ pub struct BusinessRule { pub struct RuleHit { pub rule: Uuid, pub subject: Uuid, + /// 关系结论的宾语;其他结论没有实体宾语 + pub object: Option, pub premises: Vec, pub from: Option, pub to: Option, @@ -267,6 +308,21 @@ pub fn evaluate( rules: &[BusinessRule], facts: &[AttrFact], spans: &HashMap, Option)>, + edges: &[RuleEdge], +) -> (Vec, RuleReport) { + evaluate_with_pool(rules, facts, facts, spans, edges) +} + +/// Evaluate rules with the facts available to a joined `Y` supplied separately. +/// +/// `facts` stays scoped to the conclusion's subject type. A one-hop join can +/// reach another entity type, so its conditions read from `pool_facts`. +pub fn evaluate_with_pool( + rules: &[BusinessRule], + facts: &[AttrFact], + pool_facts: &[AttrFact], + spans: &HashMap, Option)>, + edges: &[RuleEdge], ) -> (Vec, RuleReport) { let mut report = RuleReport { rules: rules.len(), @@ -274,16 +330,29 @@ pub fn evaluate( }; let mut hits: Vec = Vec::new(); - // 按实体分组:规则谈的是「一个实体自己的属性」,跨实体不参与 - let mut by_subject: HashMap> = HashMap::new(); - for f in facts { - by_subject.entry(f.subject).or_default().push(f); - } - for rule in rules { if rule.conditions.is_empty() { continue; } + if let Some(join_predicate) = rule.join_predicate { + joined_evaluate( + rule, + join_predicate, + facts, + pool_facts, + spans, + edges, + &mut hits, + &mut report, + ); + continue; + } + + // 按实体分组:这条规则谈的还是一个实体自己的属性 + let mut by_subject: HashMap> = HashMap::new(); + for f in facts { + by_subject.entry(f.subject).or_default().push(f); + } // 按组切开,组序保持稳定:同一区间被两组同时推出时,留下的是**组序在前** // 的那条证明,而不是 HashMap 顺序决定的随机一条 let groups = group_conditions(&rule.conditions); @@ -388,6 +457,7 @@ pub fn evaluate( hits.push(RuleHit { rule: rule.id, subject: *subject, + object: None, premises: combo, from, to, @@ -404,6 +474,188 @@ pub fn evaluate( (hits, report) } +/// 求值一条跨两个实体的规则(0047)。 +/// +/// **一条连接边是一对 `(X, Y)`,不是一次笛卡尔连接。** ADR 只放行一跳,并且 +/// 让每条边自己成为前提:两条同谓词边哪怕首尾一样,也可能各有一段成立期。 +/// 封顶因此按 `(rule, X, Y)` 报,与单实体规则的 `(rule, X)` 保持同一种含义。 +#[allow(clippy::too_many_arguments)] +fn joined_evaluate( + rule: &BusinessRule, + join_predicate: Uuid, + facts: &[AttrFact], + pool_facts: &[AttrFact], + spans: &HashMap, Option)>, + edges: &[RuleEdge], + hits: &mut Vec, + report: &mut RuleReport, +) { + // X is already scoped to the rule's subject type. The join, however, is + // the way the rule reaches a differently typed Y. + let mut x_by_subject: HashMap> = HashMap::new(); + for f in facts { + x_by_subject.entry(f.subject).or_default().push(f); + } + let mut pool_by_subject: HashMap> = HashMap::new(); + for f in pool_facts { + pool_by_subject.entry(f.subject).or_default().push(f); + } + + let matching: Vec<&RuleEdge> = edges + .iter() + .filter(|e| e.predicate == join_predicate) + .collect(); + let x_subjects: Vec = { + let mut xs: Vec = matching.iter().map(|e| e.subject).collect(); + xs.sort_unstable(); + xs.dedup(); + xs + }; + + for x in x_subjects { + let x_facts = x_by_subject.get(&x).map(Vec::as_slice).unwrap_or_default(); + for edge in matching.iter().filter(|e| e.subject == x) { + let y = edge.object; + let y_facts = pool_by_subject + .get(&y) + .map(Vec::as_slice) + .unwrap_or_default(); + let groups = group_conditions(&rule.conditions); + // 同一对上的多个组可能推出同一结论。留先到的组作证明,与单实体 + // 规则的去重规则一致 + let mut seen: Vec<(Option, Option, Option)> = Vec::new(); + let mut capped_here = false; + for group in &groups { + let mut slots: Vec<(Side, Uuid)> = + group.iter().map(|c| (c.side, c.predicate)).collect(); + let mut extra: Vec<(Side, Uuid)> = Vec::new(); + for c in group { + if let Operand::Calc(e) = &c.operand { + expr_predicates(e, c.side, &mut extra); + } + } + if let Conclusion::Computed { expr, .. } = &rule.conclusion { + expr_predicates(expr, Side::X, &mut extra); + } + for slot in extra { + if !slots.contains(&slot) { + slots.push(slot); + } + } + + let mut per_slot: Vec> = Vec::with_capacity(slots.len()); + let mut satisfiable = true; + for (side, predicate) in &slots { + let side_facts = match side { + Side::X => x_facts, + Side::Y => y_facts, + }; + let matched: Vec = side_facts + .iter() + .filter(|f| f.predicate == *predicate) + .map(|f| f.id) + .collect(); + if matched.is_empty() { + satisfiable = false; + break; + } + per_slot.push(matched); + } + if !satisfiable { + continue; + } + let combos: usize = per_slot.iter().map(|v| v.len()).product(); + if combos > MAX_COMBOS { + capped_here = true; + continue; + } + + let x_by_id: HashMap = + x_facts.iter().map(|f| (f.id, *f)).collect(); + let y_by_id: HashMap = + y_facts.iter().map(|f| (f.id, *f)).collect(); + for combo in cartesian(&per_slot) { + let mut x_bound: HashMap = HashMap::new(); + let mut y_bound: HashMap = HashMap::new(); + for ((side, predicate), id) in slots.iter().zip(combo.iter()) { + let bound = match side { + Side::X => &mut x_bound, + Side::Y => &mut y_bound, + }; + let facts_by_id = match side { + Side::X => &x_by_id, + Side::Y => &y_by_id, + }; + if let Some(f) = facts_by_id.get(id) { + bound.entry(*predicate).or_insert(*f); + } + } + let holds = group.iter().enumerate().all(|(i, c)| { + let bound = match c.side { + Side::X => &x_bound, + Side::Y => &y_bound, + }; + combo + .get(i) + .and_then(|id| match c.side { + Side::X => x_by_id.get(id), + Side::Y => y_by_id.get(id), + }) + .is_some_and(|f| satisfies(c, &f.value, bound)) + }); + if !holds { + continue; + } + // Relation is the only joined conclusion. Store validation + // keeps computed conclusions on the old single-entity path, + // so this branch need not invent a value for an edge. + if !matches!(rule.conclusion, Conclusion::Relation { .. }) { + continue; + } + let mut premises = combo; + premises.push(edge.id); + let Some((from, to)) = validity(&premises, spans) else { + continue; + }; + let key = (from, to, None); + if seen.contains(&key) { + continue; + } + seen.push(key); + hits.push(RuleHit { + rule: rule.id, + subject: x, + object: Some(y), + premises, + from, + to, + value: None, + }); + } + } + if capped_here { + report.capped += 1; + } + } + } +} + +fn expr_predicates(expr: &Expr, side: Side, out: &mut Vec<(Side, Uuid)>) { + match expr { + Expr::Attr(predicate) => { + let slot = (side, *predicate); + if !out.contains(&slot) { + out.push(slot); + } + } + Expr::Const(_) => {} + Expr::Arith { l, r, .. } => { + expr_predicates(l, side, out); + expr_predicates(r, side, out); + } + } +} + /// 按 `group` 切成几组,**组序按 group_seq 升序**——两组推出同一区间时, /// 留下的证明得是稳定的那一条,不能随存储顺序变。 fn group_conditions(conditions: &[Condition]) -> Vec> { @@ -488,6 +740,94 @@ fn text(v: &serde_json::Value) -> Option { } } +#[cfg(test)] +mod joined_tests { + use super::*; + use serde_json::json; + + fn id(n: u8) -> Uuid { + Uuid::from_bytes([n; 16]) + } + + fn fact(fid: u8, subject: u8, pred: u8, value: serde_json::Value) -> AttrFact { + AttrFact { + id: id(fid), + subject: id(subject), + predicate: id(pred), + value, + } + } + + /// A joined rule sees a second entity across exactly one declared edge. + /// Both sides' readings, and the edge itself, are premises. + #[test] + fn a_rule_joins_one_entity_and_concludes_a_relation() { + let rule = BusinessRule { + id: id(90), + join_predicate: Some(id(1)), + conclusion: Conclusion::Relation { predicate: id(2) }, + conditions: vec![ + Condition { + group: 0, + side: Side::X, + predicate: id(10), + op: Op::Gt, + operand: Operand::Num(50.0), + }, + Condition { + group: 0, + side: Side::Y, + predicate: id(11), + op: Op::Gt, + operand: Operand::Num(50.0), + }, + ], + }; + let facts = vec![fact(1, 50, 10, json!(60.0)), fact(2, 51, 11, json!(70.0))]; + let edges = vec![RuleEdge { + id: id(9), + predicate: id(1), + subject: id(50), + object: id(51), + }]; + let spans = HashMap::from([ + (id(1), (Some(100), Some(200))), + (id(2), (Some(150), Some(300))), + (id(9), (Some(120), None)), + ]); + let (hits, _) = evaluate(&[rule], &facts, &spans, &edges); + + assert_eq!(hits.len(), 1, "one edge yields one joined conclusion"); + assert_eq!(hits[0].subject, id(50)); + assert_eq!(hits[0].object, Some(id(51))); + assert_eq!((hits[0].from, hits[0].to), (Some(150), Some(200))); + assert!(hits[0].premises.contains(&id(1))); + assert!(hits[0].premises.contains(&id(2))); + assert!(hits[0].premises.contains(&id(9))); + } + + /// A condition on Y cannot manufacture the edge that binds it to X. + #[test] + fn a_joined_condition_without_the_edge_fires_nothing() { + let rule = BusinessRule { + id: id(90), + join_predicate: Some(id(1)), + conclusion: Conclusion::Relation { predicate: id(2) }, + conditions: vec![Condition { + group: 0, + side: Side::Y, + predicate: id(11), + op: Op::Gt, + operand: Operand::Num(50.0), + }], + }; + let facts = vec![fact(2, 51, 11, json!(70.0))]; + let spans = HashMap::from([(id(2), (Some(100), Some(200)))]); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); + assert!(hits.is_empty()); + } +} + #[cfg(test)] mod tests { use super::*; @@ -511,18 +851,21 @@ mod tests { fn a_conjunction_fires_and_names_the_two_readings() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "GasBearingWell".into(), }, conditions: vec![ Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Num(8.0), }, Condition { group: 0, + side: Side::X, predicate: id(11), op: Op::In, operand: Operand::Set(vec!["气测异常".into(), "气测异常后效".into()]), @@ -534,7 +877,7 @@ mod tests { fact(2, 50, 11, json!("气测异常")), ]; let spans = HashMap::from([(id(1), (Some(100), None)), (id(2), (Some(100), None))]); - let (hits, report) = evaluate(&[rule], &facts, &spans); + let (hits, report) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 1); assert_eq!(hits[0].subject, id(50)); assert_eq!(hits[0].premises, vec![id(1), id(2)]); @@ -548,18 +891,21 @@ mod tests { fn a_missing_condition_fires_nothing() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "GasBearingWell".into(), }, conditions: vec![ Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Num(8.0), }, Condition { group: 0, + side: Side::X, predicate: id(11), op: Op::Present, operand: Operand::None, @@ -568,7 +914,7 @@ mod tests { }; let facts = vec![fact(1, 50, 10, json!(12.3))]; let spans = HashMap::from([(id(1), (Some(100), None))]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert!(hits.is_empty(), "第二个条件没有任何事实,不该命中"); } @@ -577,11 +923,13 @@ mod tests { fn two_readings_give_two_intervals() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "GasBearingWell".into(), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Num(8.0), @@ -593,7 +941,7 @@ mod tests { (id(1), (Some(100), Some(200))), (id(2), (Some(300), Some(400))), ]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 2, "两次读数各自成立"); let mut spans_out: Vec<_> = hits.iter().map(|h| (h.from, h.to)).collect(); spans_out.sort(); @@ -609,18 +957,21 @@ mod tests { fn premises_that_never_overlapped_fire_nothing() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "GasBearingWell".into(), }, conditions: vec![ Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Num(8.0), }, Condition { group: 0, + side: Side::X, predicate: id(11), op: Op::In, operand: Operand::Set(vec!["气测异常".into()]), @@ -635,7 +986,7 @@ mod tests { (id(1), (Some(100), Some(200))), (id(2), (Some(300), Some(400))), ]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert!(hits.is_empty(), "两条前提没有同时成立的时段"); } @@ -645,6 +996,7 @@ mod tests { fn either_group_can_fire_and_carries_only_its_own_premises() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "GasBearingWell".into(), }, @@ -652,18 +1004,21 @@ mod tests { // 第 0 组:全烃 > 8 且 解释 ∈ {气测异常} Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Num(8.0), }, Condition { group: 0, + side: Side::X, predicate: id(11), op: Op::In, operand: Operand::Set(vec!["气测异常".into()]), }, // 第 1 组:综合解释 ∈ {气层} Condition { + side: Side::X, group: 1, predicate: id(12), op: Op::In, @@ -674,7 +1029,7 @@ mod tests { // 只有第二组的那条读数 let facts = vec![fact(3, 50, 12, json!("气层"))]; let spans = HashMap::from([(id(3), (Some(100), Some(200)))]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 1, "第二组独自成立"); assert_eq!(hits[0].premises, vec![id(3)]); assert_eq!((hits[0].from, hits[0].to), (Some(100), Some(200))); @@ -686,17 +1041,20 @@ mod tests { fn two_groups_on_the_same_interval_are_one_hit() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "GasBearingWell".into(), }, conditions: vec![ Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Num(8.0), }, Condition { + side: Side::X, group: 1, predicate: id(11), op: Op::In, @@ -710,7 +1068,7 @@ mod tests { (id(1), (Some(100), Some(200))), (id(2), (Some(100), Some(200))), ]); - let (hits, report) = evaluate(&[rule], &facts, &spans); + let (hits, report) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 1); assert_eq!( hits[0].premises, @@ -736,17 +1094,20 @@ mod tests { spans.insert(id(200), (Some(100), Some(200))); let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "GasBearingWell".into(), }, conditions: vec![ Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Num(8.0), }, Condition { + side: Side::X, group: 1, predicate: id(11), op: Op::In, @@ -754,7 +1115,7 @@ mod tests { }, ], }; - let (hits, report) = evaluate(&[rule], &facts, &spans); + let (hits, report) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 1, "第二组照样出结论"); assert_eq!(hits[0].premises, vec![id(200)]); assert_eq!(report.capped, 1, "(规则, 实体) 只报一次"); @@ -766,11 +1127,13 @@ mod tests { fn not_one_of_needs_a_reading_to_be_true() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "NonGas".into(), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(11), op: Op::NotIn, operand: Operand::Set(vec!["气层".into()]), @@ -781,15 +1144,17 @@ mod tests { std::slice::from_ref(&rule), &[fact(1, 50, 11, json!("水层"))], &spans, + &[], ); assert_eq!(hit.len(), 1, "读数在集合外"); let (miss, _) = evaluate( std::slice::from_ref(&rule), &[fact(1, 50, 11, json!("气层"))], &spans, + &[], ); assert!(miss.is_empty(), "读数在集合里"); - let (none, _) = evaluate(&[rule], &[], &HashMap::new()); + let (none, _) = evaluate(&[rule], &[], &HashMap::new(), &[]); assert!(none.is_empty(), "没有这条读数:不成立,而不是「不是它」"); } @@ -799,12 +1164,14 @@ mod tests { fn a_number_in_quotes_still_compares() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Attribute { predicate: id(20), value: json!("good"), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gte, operand: Operand::Num(12.0), @@ -812,7 +1179,7 @@ mod tests { }; let facts = vec![fact(1, 50, 10, json!(" 12.3 "))]; let spans = HashMap::from([(id(1), (None, None))]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 1); } @@ -823,18 +1190,20 @@ mod tests { let spans = HashMap::from([(id(1), (None, None))]); let mk = |threshold: f64| BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "GasBearingWell".into(), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Num(threshold), }], }; - assert_eq!(evaluate(&[mk(8.0)], &facts, &spans).0.len(), 1); - assert!(evaluate(&[mk(20.0)], &facts, &spans).0.is_empty()); + assert_eq!(evaluate(&[mk(8.0)], &facts, &spans, &[]).0.len(), 1); + assert!(evaluate(&[mk(20.0)], &facts, &spans, &[]).0.is_empty()); } /// 没有条件的规则什么都不推。空合取在逻辑上恒真,会把整个类归进去—— @@ -843,12 +1212,13 @@ mod tests { fn a_rule_without_conditions_concludes_nothing() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "X".into() }, conditions: vec![], }; let facts = vec![fact(1, 50, 10, json!(12.3))]; let spans = HashMap::from([(id(1), (None, None))]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert!(hits.is_empty()); } @@ -857,16 +1227,19 @@ mod tests { fn too_many_combinations_are_reported() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "X".into() }, conditions: vec![ Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Present, operand: Operand::None, }, Condition { group: 0, + side: Side::X, predicate: id(11), op: Op::Present, operand: Operand::None, @@ -881,7 +1254,7 @@ mod tests { spans.insert(id(i), (None, None)); spans.insert(id(i + 100), (None, None)); } - let (hits, report) = evaluate(&[rule], &facts, &spans); + let (hits, report) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(report.capped, 1, "100 种组合超过上限,要计数"); assert!(hits.is_empty()); } @@ -903,12 +1276,14 @@ mod tests { fn a_computed_conclusion_carries_the_readings_it_read() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Computed { predicate: id(12), expr: arith(Arith::Sub, attr(10), attr(11)), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Present, operand: Operand::None, @@ -916,7 +1291,7 @@ mod tests { }; let facts = vec![fact(1, 50, 10, json!(300.0)), fact(2, 50, 11, json!(120.0))]; let spans = HashMap::from([(id(1), (Some(100), None)), (id(2), (Some(100), None))]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 1); assert_eq!(hits[0].value, Some(180.0)); // 条件只提到 revenue,可 cost 也读了——它照样是前提 @@ -930,12 +1305,14 @@ mod tests { fn each_combination_of_readings_computes_its_own_value() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Computed { predicate: id(12), expr: arith(Arith::Sub, attr(10), attr(11)), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Present, operand: Operand::None, @@ -951,7 +1328,7 @@ mod tests { (id(2), (Some(200), Some(300))), (id(3), (Some(100), Some(300))), ]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 2, "两条 revenue 各算一个 margin"); let mut values: Vec = hits.iter().filter_map(|h| h.value).collect(); values.sort_by(f64::total_cmp); @@ -964,12 +1341,14 @@ mod tests { fn a_missing_reading_computes_nothing_rather_than_zero() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Computed { predicate: id(12), expr: arith(Arith::Sub, attr(10), attr(11)), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Present, operand: Operand::None, @@ -977,7 +1356,7 @@ mod tests { }; let facts = vec![fact(1, 50, 10, json!(300.0))]; let spans = HashMap::from([(id(1), (Some(100), None))]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert!(hits.is_empty(), "cost 没记,margin 就不该有"); } @@ -986,12 +1365,14 @@ mod tests { fn dividing_by_zero_computes_nothing() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Computed { predicate: id(12), expr: arith(Arith::Div, attr(10), attr(11)), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Present, operand: Operand::None, @@ -999,7 +1380,7 @@ mod tests { }; let facts = vec![fact(1, 50, 10, json!(300.0)), fact(2, 50, 11, json!(0.0))]; let spans = HashMap::from([(id(1), (Some(100), None)), (id(2), (Some(100), None))]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert!(hits.is_empty()); } @@ -1009,11 +1390,13 @@ mod tests { fn a_threshold_can_be_computed_from_another_reading() { let rule = |factor: f64| BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Typing { class: "Healthy".into(), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Gt, operand: Operand::Calc(arith(Arith::Mul, attr(11), Expr::Const(factor))), @@ -1022,11 +1405,11 @@ mod tests { let facts = vec![fact(1, 50, 10, json!(300.0)), fact(2, 50, 11, json!(120.0))]; let spans = HashMap::from([(id(1), (Some(100), None)), (id(2), (Some(100), None))]); - let (hits, _) = evaluate(&[rule(1.5)], &facts, &spans); + let (hits, _) = evaluate(&[rule(1.5)], &facts, &spans, &[]); assert_eq!(hits.len(), 1, "300 > 120 × 1.5"); assert_eq!(hits[0].premises.len(), 2, "门槛读的那条也是前提"); - let (none, _) = evaluate(&[rule(3.0)], &facts, &spans); + let (none, _) = evaluate(&[rule(3.0)], &facts, &spans, &[]); assert!(none.is_empty(), "300 不大于 120 × 3"); } @@ -1036,12 +1419,14 @@ mod tests { fn two_values_on_one_interval_are_two_hits() { let rule = BusinessRule { id: id(90), + join_predicate: None, conclusion: Conclusion::Computed { predicate: id(12), expr: attr(10), }, conditions: vec![Condition { group: 0, + side: Side::X, predicate: id(10), op: Op::Present, operand: Operand::None, @@ -1049,7 +1434,7 @@ mod tests { }; let facts = vec![fact(1, 50, 10, json!(300.0)), fact(2, 50, 10, json!(400.0))]; let spans = HashMap::from([(id(1), (Some(100), None)), (id(2), (Some(100), None))]); - let (hits, _) = evaluate(&[rule], &facts, &spans); + let (hits, _) = evaluate(&[rule], &facts, &spans, &[]); assert_eq!(hits.len(), 2, "同一段区间,两个值"); } diff --git a/crates/utopia-server/src/api/mcp_tests.rs b/crates/utopia-server/src/api/mcp_tests.rs index 13e6316e3..3fba687d9 100644 --- a/crates/utopia-server/src/api/mcp_tests.rs +++ b/crates/utopia-server/src/api/mcp_tests.rs @@ -1745,6 +1745,7 @@ async fn computed_rule_descriptions_keep_the_expression_tree_and_identity() -> a } let conditions = [ConditionInput { group: 2, + side: "x".into(), predicate_id: revenue, op: "present".into(), operand: None, @@ -1784,6 +1785,7 @@ async fn computed_rule_descriptions_keep_the_expression_tree_and_identity() -> a Some(margin), None, Some(expr), + None, &conditions, ) .await?; @@ -1820,6 +1822,7 @@ async fn computed_rule_descriptions_keep_the_expression_tree_and_identity() -> a None, None, None, + None, &conditions, ) .await?; @@ -2042,6 +2045,7 @@ async fn rule_descriptions_preserve_condition_groups() -> anyhow::Result<()> { .zip([("gt", 1), ("lt", 9), ("gte", 7)]) .map(|(group, (op, n))| ConditionInput { group, + side: "x".into(), predicate_id: attr, op: op.into(), operand: Some(json!(n)), @@ -2058,6 +2062,7 @@ async fn rule_descriptions_preserve_condition_groups() -> anyhow::Result<()> { Some(attr), Some(json!({"value":8})), None, + None, &cs, ) .await?; @@ -2113,6 +2118,7 @@ async fn rule_matches_keep_materialized_intervals_and_count_rows() -> anyhow::Re .await?; let conditions = [ConditionInput { group: 0, + side: "x".into(), predicate_id: reading, op: "gt".into(), operand: Some(json!(0)), @@ -2128,6 +2134,7 @@ async fn rule_matches_keep_materialized_intervals_and_count_rows() -> anyhow::Re None, None, None, + None, &conditions, ) .await?; @@ -2142,6 +2149,7 @@ async fn rule_matches_keep_materialized_intervals_and_count_rows() -> anyhow::Re Some(result), Some(json!(8)), None, + None, &conditions, ) .await?; diff --git a/crates/utopia-server/src/api/rule_routes.rs b/crates/utopia-server/src/api/rule_routes.rs index 33d3c3237..7e97eca8a 100644 --- a/crates/utopia-server/src/api/rule_routes.rs +++ b/crates/utopia-server/src/api/rule_routes.rs @@ -33,6 +33,8 @@ pub struct RuleReq { /// 算出来的结论那棵树(0032):`conclusion = "computed"` 时给 #[serde(default)] pub conclude_expr: Option, + #[serde(default)] + pub join_predicate_id: Option, pub conditions: Vec, } @@ -58,6 +60,8 @@ pub struct RulePatch { pub conclude_value: Option, #[serde(default)] pub conclude_expr: Option, + #[serde(default)] + pub join_predicate_id: Option, } pub async fn list( @@ -88,6 +92,7 @@ pub async fn create( req.conclude_predicate_id, req.conclude_value.clone(), req.conclude_expr.clone(), + req.join_predicate_id, &req.conditions, ) .await?; @@ -117,6 +122,7 @@ pub async fn update( predicate_id: req.conclude_predicate_id, value: req.conclude_value.clone(), expr: req.conclude_expr.clone(), + join_predicate_id: req.join_predicate_id, }); utopia_store::business_rules::update( &state.pool, diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index 9a0b32854..017a264bf 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -405,8 +405,11 @@ pub async fn list_rules(ctx: &ToolCtx<'_>) -> ToolResult { let mut groups: Vec<(i64, Vec)> = Vec::new(); for c in cs { let group = c["group"].as_i64().unwrap_or(0); + // Legacy conditions have no side and mean the subject; + // keep their familiar unprefixed text while disambiguating Y. + let side = if c["side"] == "y" { "Y." } else { "" }; let condition = format!( - "{} {} {}", + "{side}{} {} {}", c["predicate_label"].as_str().unwrap_or("?"), c["op"].as_str().unwrap_or("?"), c["operand"] @@ -448,6 +451,12 @@ pub async fn list_rules(ctx: &ToolCtx<'_>) -> ToolResult { .flatten() .unwrap_or_else(|| "(expression unavailable)".to_string()), ) + } else if r["conclusion"] == "relation" { + format!( + "{} from X to the Y reached by {}", + r["conclude_predicate_label"].as_str().unwrap_or("?"), + r["join_predicate_label"].as_str().unwrap_or("?"), + ) } else { format!( "{} = {}", @@ -525,13 +534,23 @@ pub async fn rule_matches(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolRe }; let from = bound("valid_from", "valid_from_precision", "unknown start"); let to = bound("valid_to", "valid_to_precision", "unknown end"); - format!( - "{} ⇒ {} (because {}) [validity: {from} → {to}] [{}]", - m["entity"].as_str().unwrap_or("?"), + let concluded = if m["object_entity"].is_null() { m["concluded"] .as_str() .map(str::to_string) - .unwrap_or_else(|| m["concluded"].to_string()), + .unwrap_or_else(|| m["concluded"].to_string()) + } else { + format!( + "{} {}", + m["relation_predicate"] + .as_str() + .unwrap_or_else(|| m["concluded"].as_str().unwrap_or("?")), + m["object_entity"].as_str().unwrap_or("?"), + ) + }; + format!( + "{} ⇒ {concluded} (because {}) [validity: {from} → {to}] [{}]", + m["entity"].as_str().unwrap_or("?"), premises, m["entity_id"].as_str().unwrap_or("?"), ) diff --git a/crates/utopia-store/src/business_rules.rs b/crates/utopia-store/src/business_rules.rs index 4e80454d5..d63091130 100644 --- a/crates/utopia-store/src/business_rules.rs +++ b/crates/utopia-store/src/business_rules.rs @@ -47,28 +47,26 @@ pub async fn ensure_is_a(pool: &PgPool, kb_id: Uuid) -> AppResult { pub const IS_A: &str = "is_a"; -/// 列表查询回来的一行规则:id、名字、说明、主类及其标签、结论那几列、 -/// 开关,以及「此刻凭它成立的结论条数」。 -/// -/// 起个名字而不是让它当匿名元组:这一行有十三格,读的人对不上位置 -type RuleRow = ( - Uuid, - String, - String, - Uuid, - Option, - String, - Option, - Option, - Option, - Option, - Option, - // 算出来的结论那棵树(0032) - Option, - bool, - i64, - i32, -); +#[derive(sqlx::FromRow)] +struct RuleRow { + id: Uuid, + name: String, + description: String, + subject_type_id: Uuid, + subject_label: Option, + conclusion: String, + conclude_type_id: Option, + conclude_type_label: Option, + conclude_predicate_id: Option, + conclude_predicate_label: Option, + conclude_value: Option, + conclude_expr: Option, + enabled: bool, + join_predicate_id: Option, + join_predicate_label: Option, + derived_count: i64, + capped: i32, +} /// 条件查询回来的一行:规则、组号、属性谓词及其标签、比较方式、操作数 type ConditionRow = ( @@ -78,6 +76,7 @@ type ConditionRow = ( Option, String, Option, + String, ); /// 一条条件,界面与 API 共用的形状。 @@ -91,6 +90,14 @@ pub struct ConditionInput { pub op: String, #[serde(default)] pub operand: Option, + /// Which side of a joined pair this condition reads: `x` is the rule + /// subject and `y` is the entity reached by the one declared join edge. + #[serde(default = "default_condition_side")] + pub side: String, +} + +fn default_condition_side() -> String { + "x".to_string() } fn validate_name(name: &str) -> AppResult<&str> { @@ -119,6 +126,7 @@ pub async fn create( conclude_value: Option, // 算出来的结论那棵树(0032) conclude_expr: Option, + join_predicate_id: Option, conditions: &[ConditionInput], ) -> AppResult { let name = validate_name(name)?; @@ -129,7 +137,7 @@ pub async fn create( "A rule needs at least one condition; without one it would conclude for every entity of the class.", )); } - validate_conditions(pool, kb_id, conditions).await?; + validate_conditions(pool, kb_id, conditions, conclusion == "relation").await?; validate_conclusion( pool, @@ -140,9 +148,11 @@ pub async fn create( predicate_id: conclude_predicate_id, value: conclude_value.clone(), expr: conclude_expr.clone(), + join_predicate_id, }, ) .await?; + validate_join_shape(conclusion, join_predicate_id)?; exists( pool, kb_id, @@ -158,8 +168,9 @@ pub async fn create( sqlx::query( "INSERT INTO attribute_rules (id, kb_id, name, description, subject_type_id, conclusion, - conclude_type_id, conclude_predicate_id, conclude_value, conclude_expr) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + conclude_type_id, conclude_predicate_id, conclude_value, conclude_expr, + join_predicate_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", ) .bind(id) .bind(kb_id) @@ -171,6 +182,7 @@ pub async fn create( .bind(conclude_predicate_id) .bind(&conclude_value) .bind(&conclude_expr) + .bind(join_predicate_id) .execute(&mut *tx) .await .map_err(|e| match e { @@ -199,6 +211,8 @@ pub struct ConclusionInput { pub value: Option, /// 算出来的结论那棵树(0032)。`computed` 时必给,别的两支必空 pub expr: Option, + /// X --join--> Y 的那条边,只在 relation 结论时非空 + pub join_predicate_id: Option, } #[allow(clippy::too_many_arguments)] @@ -220,10 +234,30 @@ pub async fn update( "A rule needs at least one condition.", )); } - validate_conditions(pool, kb_id, cs).await?; + let (old_conclusion, old_join) = sqlx::query_as::<_, (String, Option)>( + "SELECT conclusion, join_predicate_id + FROM attribute_rules + WHERE id = $2 AND kb_id = $1", + ) + .bind(kb_id) + .bind(rule_id) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound)?; + let joined = conclusion + .map(|c| c.kind.as_str() == "relation") + .unwrap_or(old_conclusion == "relation"); + validate_conditions(pool, kb_id, cs, joined).await?; + let join = if joined { + conclusion.and_then(|c| c.join_predicate_id).or(old_join) + } else { + None + }; + validate_join_shape(if joined { "relation" } else { "other" }, join)?; } if let Some(c) = conclusion { validate_conclusion(pool, kb_id, c).await?; + validate_join_shape(&c.kind, c.join_predicate_id)?; } let mut tx = pool.begin().await?; let res = sqlx::query( @@ -239,6 +273,7 @@ pub async fn update( conclude_predicate_id = CASE WHEN $6 IS NULL THEN conclude_predicate_id ELSE $8 END, conclude_value = CASE WHEN $6 IS NULL THEN conclude_value ELSE $9 END, conclude_expr = CASE WHEN $6 IS NULL THEN conclude_expr ELSE $10 END, + join_predicate_id = CASE WHEN $6 IS NULL THEN join_predicate_id ELSE $11 END, updated_at = now() WHERE id = $2 AND kb_id = $1", ) @@ -252,6 +287,7 @@ pub async fn update( .bind(conclusion.and_then(|c| c.predicate_id)) .bind(conclusion.and_then(|c| c.value.clone())) .bind(conclusion.and_then(|c| c.expr.clone())) + .bind(conclusion.and_then(|c| c.join_predicate_id)) .execute(&mut *tx) .await?; if res.rows_affected() == 0 { @@ -285,16 +321,21 @@ pub async fn delete(pool: &PgPool, kb_id: Uuid, rule_id: Uuid) -> AppResult<()> /// 列出规则,连同条件与「现在推出了多少条」。 pub async fn list(pool: &PgPool, kb_id: Uuid) -> AppResult> { let rules: Vec = sqlx::query_as( - "SELECT r.id, r.name, r.description, r.subject_type_id, st.label, - r.conclusion, r.conclude_type_id, ct.label, - r.conclude_predicate_id, cp.label, r.conclude_value, r.conclude_expr, r.enabled, + "SELECT r.id, r.name, r.description, r.subject_type_id, + st.label AS subject_label, + r.conclusion, r.conclude_type_id, ct.label AS conclude_type_label, + r.conclude_predicate_id, cp.label AS conclude_predicate_label, + r.conclude_value, r.conclude_expr, r.enabled, + r.join_predicate_id, jp.label AS join_predicate_label, (SELECT count(*) FROM derived_facts d - WHERE d.attribute_rule_id = r.id AND d.invalidated_at IS NULL), - r.capped_at_last_run + WHERE d.attribute_rule_id = r.id AND d.invalidated_at IS NULL) + AS derived_count, + r.capped_at_last_run AS capped FROM attribute_rules r JOIN entity_types st ON st.id = r.subject_type_id LEFT JOIN entity_types ct ON ct.id = r.conclude_type_id LEFT JOIN relation_types cp ON cp.id = r.conclude_predicate_id + LEFT JOIN relation_types jp ON jp.id = r.join_predicate_id WHERE r.kb_id = $1 ORDER BY r.created_at", ) @@ -304,9 +345,10 @@ pub async fn list(pool: &PgPool, kb_id: Uuid) -> AppResult = rules.iter().map(|r| r.0).collect(); + let ids: Vec = rules.iter().map(|r| r.id).collect(); let conds: Vec = sqlx::query_as( - "SELECT c.rule_id, c.group_seq, c.predicate_id, p.label, c.op, c.operand + "SELECT c.rule_id, c.group_seq, c.predicate_id, p.label, c.op, c.operand, + c.subject_side FROM attribute_rule_conditions c JOIN relation_types p ON p.id = c.predicate_id WHERE c.rule_id = ANY($1) @@ -318,57 +360,42 @@ pub async fn list(pool: &PgPool, kb_id: Uuid) -> AppResult = conds - .iter() - .filter(|c| c.0 == id) - .map(|(_, group, pid, plabel, op, operand)| { - json!({ - "group": group, - "predicate_id": pid, - "predicate_label": plabel, - "op": op, - "operand": operand, - }) + .map(|r| { + let conditions: Vec = conds + .iter() + .filter(|c| c.0 == r.id) + .map(|(_, group, pid, plabel, op, operand, side)| { + json!({ + "group": group, + "side": side, + "predicate_id": pid, + "predicate_label": plabel, + "op": op, + "operand": operand, }) - .collect(); - json!({ - "id": id, - "name": name, - "description": description, - "subject_type_id": subject_type_id, - "subject_label": subject_label, - "conclusion": conclusion, - "conclude_type_id": ct, - "conclude_type_label": ct_label, - "conclude_predicate_id": cp, - "conclude_predicate_label": cp_label, - "conclude_value": cv, - "conclude_expr": cx, - "enabled": enabled, - "derived_count": derived, - "capped": capped, - "conditions": conditions, }) - }, - ) + .collect(); + json!({ + "id": r.id, + "name": r.name, + "description": r.description, + "subject_type_id": r.subject_type_id, + "subject_label": r.subject_label, + "conclusion": r.conclusion, + "conclude_type_id": r.conclude_type_id, + "conclude_type_label": r.conclude_type_label, + "conclude_predicate_id": r.conclude_predicate_id, + "conclude_predicate_label": r.conclude_predicate_label, + "conclude_value": r.conclude_value, + "conclude_expr": r.conclude_expr, + "join_predicate_id": r.join_predicate_id, + "join_predicate_label": r.join_predicate_label, + "enabled": r.enabled, + "derived_count": r.derived_count, + "capped": r.capped, + "conditions": conditions, + }) + }) .collect()) } @@ -448,6 +475,9 @@ type MatchRow = ( Uuid, Uuid, String, + Option, + Option, + Option, Option, Option>, Option>, @@ -480,9 +510,11 @@ pub async fn matches( // 结论读出来要是人看的那个名字。库里存的是类的 key/IRI(归类)或 // 字面值(属性),两者都不该原样端上来 "SELECT d.id, e.id, e.canonical_name, + o.id, o.canonical_name, COALESCE(ct.label, d.object_value #>> '{value}', d.object_value ->> 'class'), + rp.label, d.valid_from, d.valid_to, d.valid_from_precision, d.valid_to_precision, COALESCE( (SELECT array_agg( @@ -497,8 +529,10 @@ pub async fn matches( ) FROM derived_facts d JOIN entities e ON e.id = d.subject_id + LEFT JOIN entities o ON o.id = d.object_id JOIN attribute_rules ar ON ar.id = d.attribute_rule_id LEFT JOIN entity_types ct ON ct.id = ar.conclude_type_id + LEFT JOIN relation_types rp ON rp.id = d.predicate_id WHERE d.kb_id = $1 AND d.attribute_rule_id = $2 AND d.invalidated_at IS NULL ORDER BY e.canonical_name, d.valid_from, d.id LIMIT $3 OFFSET $4", @@ -513,12 +547,28 @@ pub async fn matches( Ok(( rows.into_iter() .map( - |(id, entity_id, name, concluded, from, to, fp, tp, premises)| { + |( + id, + entity_id, + name, + object_id, + object_name, + concluded, + relation_label, + from, + to, + fp, + tp, + premises, + )| { json!({ "derived_id": id, "entity_id": entity_id, "entity": name, + "object_id": object_id, + "object_entity": object_name, "concluded": concluded, + "relation_predicate": relation_label, "valid_from": from, "valid_to": to, "valid_from_precision": fp, @@ -543,8 +593,8 @@ async fn insert_conditions( let seq = next.entry(c.group).or_insert(0); sqlx::query( "INSERT INTO attribute_rule_conditions - (id, rule_id, group_seq, seq, predicate_id, op, operand) - VALUES ($1, $2, $3, $4, $5, $6, $7)", + (id, rule_id, group_seq, seq, predicate_id, op, operand, subject_side) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) .bind(Uuid::now_v7()) .bind(rule_id) @@ -553,6 +603,7 @@ async fn insert_conditions( .bind(c.predicate_id) .bind(&c.op) .bind(&c.operand) + .bind(&c.side) .execute(&mut **tx) .await?; *seq += 1; @@ -625,9 +676,21 @@ async fn validate_conclusion(pool: &PgPool, kb_id: Uuid, c: &ConclusionInput) -> } attribute_predicate(pool, kb_id, p).await } + // A relation conclusion names the edge itself, so both ends are + // entities. The join edge and the conclusion may be different declared + // predicates: supplies supplies(Y, Z) can conclude upstream_of(X, Z). + "relation" => { + let p = c.predicate_id.ok_or_else(|| { + AppError::invalid( + "no_predicate", + "A relation rule needs the relation it concludes.", + ) + })?; + relation_predicate(pool, kb_id, p).await + } _ => Err(AppError::invalid( "bad_conclusion", - "A conclusion is a typing, an attribute or a computed attribute.", + "A conclusion is a typing, an attribute, a computed attribute or a relation.", )), } } @@ -679,6 +742,7 @@ async fn validate_conditions( pool: &PgPool, kb_id: Uuid, conditions: &[ConditionInput], + joined: bool, ) -> AppResult<()> { for c in conditions { let op = utopia_reason::rules::Op::parse(&c.op).ok_or_else(|| { @@ -687,6 +751,18 @@ async fn validate_conditions( "A condition compares with >, >=, <, <=, a range, a set (in or not in), or presence.", ) })?; + let side = utopia_reason::rules::Side::parse(&c.side).ok_or_else(|| { + AppError::invalid( + "bad_condition_side", + "A condition reads x (the rule's subject) or y (the joined entity).", + ) + })?; + if !joined && side != utopia_reason::rules::Side::X { + return Err(AppError::invalid( + "condition_side_without_join", + "Only a joined rule can read the other side of an edge.", + )); + } attribute_predicate(pool, kb_id, c.predicate_id).await?; // ADR 0032 already permits expression thresholds. Validate the same AST // and same-base attribute references as computed conclusions; sets, @@ -761,6 +837,42 @@ async fn attribute_predicate(pool: &PgPool, kb_id: Uuid, id: Uuid) -> AppResult< } } +async fn relation_predicate(pool: &PgPool, kb_id: Uuid, id: Uuid) -> AppResult<()> { + let row: Option<(String,)> = + sqlx::query_as("SELECT kind FROM relation_types WHERE id = $2 AND kb_id = $1") + .bind(kb_id) + .bind(id) + .fetch_optional(pool) + .await?; + match row.as_ref().map(|(k,)| k.as_str()) { + Some("relation") => Ok(()), + Some(_) => Err(AppError::invalid( + "not_a_relation", + "A join or relation conclusion names an edge between two entities.", + )), + None => Err(AppError::invalid( + "unknown_predicate", + "That relation is not in this base.", + )), + } +} + +/// Keep the conclusion and its join edge as one replaceable shape. The database +/// CHECK is the last line of defence; these errors say which half is missing. +fn validate_join_shape(kind: &str, join_predicate_id: Option) -> AppResult<()> { + match (kind == "relation", join_predicate_id.is_some()) { + (true, true) | (false, false) => Ok(()), + (true, false) => Err(AppError::invalid( + "no_join_predicate", + "A relation rule needs the relation that connects X to Y.", + )), + (false, true) => Err(AppError::invalid( + "join_without_relation", + "Only a relation conclusion can name a join predicate.", + )), + } +} + async fn exists( pool: &PgPool, kb_id: Uuid, diff --git a/crates/utopia-store/src/reasoning.rs b/crates/utopia-store/src/reasoning.rs index 5a2519ca0..908a10e8d 100644 --- a/crates/utopia-store/src/reasoning.rs +++ b/crates/utopia-store/src/reasoning.rs @@ -1007,6 +1007,7 @@ fn scoped_by_conclusion( for group in groups { conditions.push(Condition { group, + side: utopia_reason::rules::Side::X, predicate: is_a, op: Op::In, operand: Operand::Set(classes.to_vec()), @@ -1015,6 +1016,7 @@ fn scoped_by_conclusion( utopia_reason::rules::BusinessRule { id: rule.id, conclusion: rule.conclusion.clone(), + join_predicate: rule.join_predicate, conditions, } } @@ -1192,9 +1194,51 @@ type DerivedKey = ( Option, ); +type AssertedEdges = HashMap<(Uuid, Uuid, Uuid), Vec<(Option, Option)>>; + +fn merge_axiom_derivation( + derivation: &utopia_reason::derive::Derivation, + blocked: &HashSet, + spans: &HashMap, Option)>, + rules: &HashMap<(Uuid, RuleKind), Uuid>, + wanted: &mut HashMap, +) -> (usize, usize) { + let before = wanted.len(); + let mut unruled = 0usize; + for (i, d) in derivation.facts.iter().enumerate() { + if blocked.contains(&i) { + continue; + } + let Some((from, to)) = utopia_reason::derive::validity(&d.premises, spans) else { + continue; + }; + // **按 `via` 查,不是 `predicate`。** 跨谓词的规则里,派生出来的谓词 + // 是另一个;落到账本前必须先找到触发它的规则行。 + let Some(&rule_id) = rules.get(&(d.via, d.rule.as_str())) else { + unruled += 1; + continue; + }; + wanted + .entry((d.subject, d.predicate, Some(d.object), None, from, to)) + .or_insert(Wanted { + subject: d.subject, + predicate: d.predicate, + object_id: Some(d.object), + object_value: None, + from, + to, + premises: d.premises.clone(), + rule_id: Some(rule_id), + attribute_rule_id: None, + }); + } + (wanted.len() - before, unruled) +} + /// 这一轮要落库的一条派生。公理推出来的与规则推出来的在这里合流—— /// **合流是必须的**:陈旧行的对账扫的是整张表,两趟各做各的 diff 会把对方的 /// 行每轮都判成陈旧作废掉。 +#[derive(Clone)] struct Wanted { subject: Uuid, predicate: Uuid, @@ -1355,6 +1399,7 @@ type RuleDefRow = ( Option, Option, Option, + Option, ); /// 取业务规则。条件形状不合法的规则**整条跳过而不是报错退出**——一条写坏的 @@ -1368,7 +1413,7 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { let rows: Vec = sqlx::query_as( "SELECT r.id, r.subject_type_id, r.conclusion, r.conclude_type_id, r.conclude_predicate_id, r.conclude_value, - r.conclude_expr, ct.iri, ct.key + r.conclude_expr, ct.iri, ct.key, r.join_predicate_id FROM attribute_rules r LEFT JOIN entity_types ct ON ct.id = r.conclude_type_id WHERE r.kb_id = $1 AND r.enabled @@ -1410,8 +1455,8 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { let ids: Vec = rows.iter().map(|r| r.0).collect(); // 组序在前:两组推出同一区间时,留下的证明得是稳定的那一条(0029) - let conds: Vec<(Uuid, i32, Uuid, String, Option)> = sqlx::query_as( - "SELECT rule_id, group_seq, predicate_id, op, operand + let conds: Vec<(Uuid, i32, Uuid, String, Option, String)> = sqlx::query_as( + "SELECT rule_id, group_seq, predicate_id, op, operand, subject_side FROM attribute_rule_conditions WHERE rule_id = ANY($1) ORDER BY rule_id, group_seq, seq", @@ -1421,7 +1466,7 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { .await?; let mut by_rule: HashMap> = HashMap::new(); let mut broken: HashSet = HashSet::new(); - for (rule_id, group, predicate, op, operand) in conds { + for (rule_id, group, predicate, op, operand, side) in conds { let Some(op) = Op::parse(&op) else { broken.insert(rule_id); continue; @@ -1432,6 +1477,7 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { }; by_rule.entry(rule_id).or_default().push(Condition { group, + side: utopia_reason::rules::Side::parse(&side).unwrap_or(utopia_reason::rules::Side::X), predicate, op, operand, @@ -1449,6 +1495,7 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { conclude_expr, iri, key, + join_predicate, ) in rows { if broken.contains(&id) { @@ -1490,6 +1537,14 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { p, ) } + // The edge conclusion uses the relation predicate directly; the + // join edge itself arrives in the evaluator as another premise. + "relation" => { + let (Some(p), Some(_)) = (conclude_pred, join_predicate) else { + continue; + }; + (Conclusion::Relation { predicate: p }, p) + } _ => continue, }; let subject_types = descendants_of(pool, kb_id, subject_type).await?; @@ -1501,6 +1556,7 @@ async fn attribute_rules(pool: &PgPool, kb_id: Uuid) -> AppResult { rule: BusinessRule { id, conclusion, + join_predicate, conditions, }, subject_types, @@ -1721,47 +1777,28 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult blocked.insert(*j); } } + let mut blocked_triples: HashSet<(Uuid, Uuid, Uuid)> = blocked + .iter() + .map(|&i| { + let d = &derivation.facts[i]; + (d.subject, d.predicate, d.object) + }) + .collect(); let mut report = DeriveReport { rules: rules.len(), edges: edges.len(), - derived: derivation.facts.len(), + derived: 0, capped: derivation.capped.len(), blocked: blocked.len(), ..Default::default() }; let mut wanted: HashMap = HashMap::new(); - for (i, d) in derivation.facts.iter().enumerate() { - if blocked.contains(&i) { - continue; - } - let Some((from, to)) = utopia_reason::derive::validity(&d.premises, &spans) else { - continue; - }; - // **按 `via` 查,不是 `predicate`。** 规则行是给「声明了公理的那个 - // 谓词」编的;跨谓词的两条规则里,派生出来的谓词是另一个 - let Some(&rule_id) = rules.get(&(d.via, d.rule.as_str())) else { - // 查不到规则是**编译与推导不一致**,不是正常情况。数出来, - // 别再让它静默消失一次 - report.unruled += 1; - continue; - }; - wanted.insert( - (d.subject, d.predicate, Some(d.object), None, from, to), - Wanted { - subject: d.subject, - predicate: d.predicate, - object_id: Some(d.object), - object_value: None, - from, - to, - premises: d.premises.clone(), - rule_id: Some(rule_id), - attribute_rule_id: None, - }, - ); - } + let (initial_axioms, unruled) = + merge_axiom_derivation(&derivation, &blocked, &spans, &rules, &mut wanted); + report.derived += initial_axioms; + report.unruled += unruled; // 第二趟:属性事实上的业务规则(0021)。**并进同一个 `wanted`**—— // 下面的陈旧对账扫的是整张 `derived_facts`,两趟各做各的 diff 会把对方 @@ -1777,6 +1814,30 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult // 区间也要——认出派生的哪一端是被前提的锚点顶上来的,靠的就是它 meta.extend(attr_meta); spans.extend(attr_spans); + let edge_pool: Vec = edges + .iter() + .map(|e| utopia_reason::rules::RuleEdge { + id: e.edge.fact, + predicate: e.edge.predicate, + subject: e.edge.subject, + object: e.edge.object, + }) + .collect(); + let mut relation_edges: Vec = Vec::new(); + let mut relation_keys: Vec = Vec::new(); + let mut relation_candidates: HashMap = HashMap::new(); + let asserted_edges: AssertedEdges = edges + .iter() + .map(|e| { + ( + (e.edge.subject, e.edge.predicate, e.edge.object), + (e.from, e.to), + ) + }) + .fold(HashMap::new(), |mut acc, (key, span)| { + acc.entry(key).or_default().push(span); + acc + }); // ---- 不动点:这一轮的结论进下一轮的输入(0030) // @@ -1794,6 +1855,112 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult let mut rounds = 0usize; for _ in 0..utopia_reason::MAX_DEPTH { rounds += 1; + // Rule edges have provisional ids in `spans`. They enter the axiom + // pool as ordinary timed edges, while the derivation and clash check + // also see them as candidate conclusions for this round. + let mut timed: Vec = edges.clone(); + timed.extend(relation_edges.iter().map(|e| { + let (from, to) = spans.get(&e.id).copied().unwrap_or_default(); + utopia_reason::derive::TimedEdge { + edge: utopia_reason::Edge { + fact: e.id, + predicate: e.predicate, + subject: e.subject, + object: e.object, + }, + from, + to, + } + })); + let derivation = + utopia_reason::derive::derive_with_blocked(&timed, &ax, &blocked_triples); + let mut candidates = derivation.facts.clone(); + for (edge, key) in relation_edges.iter().zip(&relation_keys) { + let wanted = relation_candidates + .get(key) + .expect("relation candidate exists"); + candidates.push(utopia_reason::derive::Derived { + predicate: edge.predicate, + via: edge.predicate, + subject: edge.subject, + object: edge.object, + // The axiom enum cannot name a business rule. This value is + // only used to group clash reports; blocked indexes below + // map back to the relation candidate before persistence. + rule: utopia_reason::derive::Rule::Transitive, + premises: wanted.premises.clone(), + }); + } + let candidate_derivation = utopia_reason::derive::Derivation { + facts: candidates, + capped: derivation.capped.clone(), + }; + let clashes = + utopia_reason::derive::contradictions(&candidate_derivation, &timed, &ax, &spans); + let mut blocked = clashes.blocked(); + blocked.retain(|&i| { + if let Some(c) = clashes.with_assertions.iter().find(|c| c.derived == i) { + let d = &candidate_derivation.facts[i]; + return !accepted.contains(&(d.subject, d.predicate, d.object, c.against)); + } + true + }); + blocked_triples = blocked + .iter() + .map(|&i| { + let d = &candidate_derivation.facts[i]; + (d.subject, d.predicate, d.object) + }) + .collect(); + let axiom_fact_count = derivation.facts.len(); + let blocked_relations: HashSet = blocked + .iter() + .filter(|&&i| i >= axiom_fact_count) + .map(|i| i - axiom_fact_count) + .collect(); + if !blocked_relations.is_empty() { + let mut edges = Vec::new(); + let mut keys = Vec::new(); + let mut candidates = HashMap::new(); + for (i, (edge, key)) in relation_edges + .iter() + .cloned() + .zip(relation_keys.iter().cloned()) + .enumerate() + { + if blocked_relations.contains(&i) { + continue; + } + let wanted = relation_candidates.get(&key).cloned().expect("candidate"); + edges.push(edge); + keys.push(key.clone()); + candidates.insert(key, wanted); + } + let blocked_keys: HashSet = blocked_relations + .iter() + .filter_map(|&i| relation_keys.get(i)) + .cloned() + .collect(); + relation_edges = edges; + relation_keys = keys; + relation_candidates = candidates; + for key in &blocked_keys { + provisional.remove(key); + wanted.remove(key); + } + } + wanted.retain(|key, _| { + key.2 + .is_none_or(|object| !blocked_triples.contains(&(key.0, key.1, object))) + }); + + let (new_axioms, _) = + merge_axiom_derivation(&derivation, &blocked, &spans, &rules, &mut wanted); + report.derived += new_axioms; + report.capped = derivation.capped.len(); + report.blocked = blocked.len(); + let mut evaluation_edges = edge_pool.clone(); + evaluation_edges.extend(relation_edges.iter().cloned()); // 一轮之内先算完再入池:同一轮里规则读到的是上一轮结束时的池子, // 谁先谁后就不影响结果 let mut fresh: Vec<(usize, utopia_reason::rules::RuleHit)> = Vec::new(); @@ -1817,10 +1984,12 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult by_conclusion.push(f.clone()); } } - let (hits, rr) = utopia_reason::rules::evaluate( + let (hits, rr) = utopia_reason::rules::evaluate_with_pool( std::slice::from_ref(&lr.rule), &by_assertion, + &fact_pool, &spans, + &evaluation_edges, ); let mut capped = rr.capped; fresh.extend(hits.into_iter().map(|h| (ri, h))); @@ -1832,10 +2001,12 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult if !by_conclusion.is_empty() { if let (Some(is_a), false) = (loaded.is_a, lr.subject_classes.is_empty()) { let scoped = scoped_by_conclusion(&lr.rule, is_a, &lr.subject_classes); - let (h2, rr2) = utopia_reason::rules::evaluate( + let (h2, rr2) = utopia_reason::rules::evaluate_with_pool( std::slice::from_ref(&scoped), &by_conclusion, + &fact_pool, &spans, + &evaluation_edges, ); capped += rr2.capped; fresh.extend(h2.into_iter().map(|h| (ri, h))); @@ -1844,9 +2015,63 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult *capped_by_rule.entry(lr.rule.id).or_default() += capped; } - let before = provisional.len(); + let before = wanted.len(); for (ri, h) in fresh { let lr = &loaded.rules[ri]; + if matches!( + lr.rule.conclusion, + utopia_reason::rules::Conclusion::Relation { .. } + ) { + let Some(object) = h.object else { continue }; + let key = ( + h.subject, + lr.conclude_predicate, + Some(object), + None, + h.from, + h.to, + ); + if wanted.contains_key(&key) { + continue; + } + let overlaps_assertion = asserted_edges + .get(&(h.subject, lr.conclude_predicate, object)) + .is_some_and(|spans| { + spans.iter().any(|&(from, to)| { + utopia_reason::derive::overlap((h.from, h.to), (from, to)).is_some() + }) + }); + if overlaps_assertion { + continue; + } + let prov = Uuid::now_v7(); + let pm = premise_meta(&h.premises, h.from, h.to, &spans, &meta); + spans.insert(prov, (h.from, h.to)); + meta.insert(prov, pm); + relation_edges.push(utopia_reason::rules::RuleEdge { + id: prov, + predicate: lr.conclude_predicate, + subject: h.subject, + object, + }); + relation_keys.push(key.clone()); + let candidate = Wanted { + subject: h.subject, + predicate: lr.conclude_predicate, + object_id: Some(object), + object_value: None, + from: h.from, + to: h.to, + premises: h.premises, + rule_id: None, + attribute_rule_id: Some(lr.rule.id), + }; + relation_candidates.insert(key.clone(), candidate.clone()); + let wanted_candidate = candidate.clone(); + provisional.insert(key.clone(), prov); + wanted.insert(key, wanted_candidate); + continue; + } let (value, inner) = match &lr.rule.conclusion { utopia_reason::rules::Conclusion::Typing { class } => ( serde_json::json!({ "class": class }), @@ -1865,6 +2090,9 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult let v = serde_json::Value::Number(v); (serde_json::json!({ "value": v }), v) } + // The relation arm above stores an entity edge and returns + // before this value-shaping match. + utopia_reason::rules::Conclusion::Relation { .. } => unreachable!(), }; let key = ( h.subject, diff --git a/crates/utopia-store/tests/a_rule_concludes_a_relation.rs b/crates/utopia-store/tests/a_rule_concludes_a_relation.rs new file mode 100644 index 000000000..cc82fd064 --- /dev/null +++ b/crates/utopia-store/tests/a_rule_concludes_a_relation.rs @@ -0,0 +1,285 @@ +//! A joined rule has to see both ends of the edge in the database (0047). +//! +//! The pure evaluator already knows `Side::Y`. This file pins the loader +//! contract behind it: `X` remains scoped by the rule subject type, while `Y` +//! may be any entity the declared join reaches. It also checks that the +//! persisted row carries its object and the full three-part proof. + +use sqlx::PgPool; +use utopia_store::business_rules::ConditionInput; +use uuid::Uuid; + +struct Fixture { + org: Uuid, + kb: Uuid, + well: Uuid, + pressure: Uuid, + depth: Uuid, + supplies: Uuid, + upstream_of: Uuid, + x: Uuid, + y: Uuid, +} + +type DerivedRows = Vec<( + Uuid, + Uuid, + Uuid, + Option, + chrono::DateTime, +)>; + +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (well, field) = (Uuid::now_v7(), Uuid::now_v7()); + let (pressure, depth) = (Uuid::now_v7(), Uuid::now_v7()); + let (supplies, upstream_of) = (Uuid::now_v7(), Uuid::now_v7()); + let (x, y) = (Uuid::now_v7(), Uuid::now_v7()); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'join-rule-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'join-rule-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'join-rule-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + for (id, key, label) in [(well, "well", "Well"), (field, "field", "Field")] { + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, $3, $4)") + .bind(id) + .bind(kb) + .bind(key) + .bind(label) + .execute(pool) + .await?; + } + for (id, key, kind, datatype) in [ + (pressure, "pressure", "attribute", "number"), + (depth, "depth", "attribute", "number"), + ] { + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind, datatype) + VALUES ($1, $2, $3, $3, $4, $5)", + ) + .bind(id) + .bind(kb) + .bind(key) + .bind(kind) + .bind(datatype) + .execute(pool) + .await?; + } + for (id, key) in [(supplies, "supplies"), (upstream_of, "upstream_of")] { + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind) + VALUES ($1, $2, $3, $3, 'relation')", + ) + .bind(id) + .bind(kb) + .bind(key) + .execute(pool) + .await?; + } + for (id, type_id, name) in [(x, well, "W-1"), (y, field, "F-2")] { + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(kb) + .bind(type_id) + .bind(name) + .execute(pool) + .await?; + } + + Ok(Fixture { + org, + kb, + well, + pressure, + depth, + supplies, + upstream_of, + x, + y, + }) +} + +async fn attr( + pool: &PgPool, + f: &Fixture, + subject: Uuid, + predicate: Uuid, + value: f64, + from: &str, +) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_value, + valid_from, valid_from_precision, confidence) + VALUES ($1, $2, $3, $4, $5, $6, 'day', 0.9)", + ) + .bind(id) + .bind(f.kb) + .bind(subject) + .bind(predicate) + .bind(serde_json::json!({ "value": value })) + .bind(from.parse::>()?) + .execute(pool) + .await?; + Ok(id) +} + +async fn edge( + pool: &PgPool, + f: &Fixture, + predicate: Uuid, + from: &str, + to: &str, +) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, + valid_from, valid_from_precision, + valid_to, valid_to_precision, confidence) + VALUES ($1, $2, $3, $4, $5, $6, 'day', $7, 'day', 0.9)", + ) + .bind(id) + .bind(f.kb) + .bind(f.x) + .bind(predicate) + .bind(f.y) + .bind(from.parse::>()?) + .bind(to.parse::>()?) + .execute(pool) + .await?; + Ok(id) +} + +fn conditions(f: &Fixture) -> [ConditionInput; 2] { + [ + ConditionInput { + group: 0, + predicate_id: f.pressure, + op: "gt".into(), + operand: Some(serde_json::json!(80.0)), + side: "x".into(), + }, + ConditionInput { + group: 0, + predicate_id: f.depth, + op: "lt".into(), + operand: Some(serde_json::json!(500.0)), + side: "y".into(), + }, + ] +} + +#[tokio::test] +async fn a_joined_rule_reads_the_other_side_of_a_declared_edge() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let run = async { + // An older assertion of the same edge must only win where it holds. + // The rule reads a later supply, so this earlier interval must not + // suppress the derived conclusion for February onward. + let _asserted_upstream = edge( + &pool, + &f, + f.upstream_of, + "2024-01-01T00:00:00Z", + "2024-01-15T00:00:00Z", + ) + .await?; + let pressure = attr(&pool, &f, f.x, f.pressure, 120.0, "2024-01-01T00:00:00Z").await?; + let depth = attr(&pool, &f, f.y, f.depth, 300.0, "2024-01-15T00:00:00Z").await?; + let join = edge( + &pool, + &f, + f.supplies, + "2024-01-01T00:00:00Z", + "2024-02-01T00:00:00Z", + ) + .await?; + + utopia_store::business_rules::create( + &pool, + f.kb, + "upstream high pressure", + "", + f.well, + "relation", + None, + Some(f.upstream_of), + None, + None, + Some(f.supplies), + &conditions(&f), + ) + .await?; + + let report = utopia_store::reasoning::materialize(&pool, f.kb).await?; + assert_eq!(report.attribute_rules, 1); + assert_eq!( + report.rule_hits, 1, + "the Y reading is outside the rule subject type but inside the declared join" + ); + + let rows: DerivedRows = sqlx::query_as( + "SELECT id, subject_id, predicate_id, object_id, valid_from + FROM derived_facts + WHERE kb_id = $1 AND invalidated_at IS NULL", + ) + .bind(f.kb) + .fetch_all(&pool) + .await?; + assert_eq!(rows.len(), 1); + let (derived, subject, predicate, object, from) = rows[0]; + assert_eq!(subject, f.x); + assert_eq!(predicate, f.upstream_of); + assert_eq!(object, Some(f.y)); + assert_eq!( + from.to_rfc3339(), + "2024-01-15T00:00:00+00:00", + "validity starts at the latest of the three premises" + ); + + let premises: Vec<(Option, Option)> = sqlx::query_as( + "SELECT premise_fact_id, premise_derived_id + FROM fact_derivations + WHERE derived_fact_id = $1 + ORDER BY seq", + ) + .bind(derived) + .fetch_all(&pool) + .await?; + let asserted: Vec = premises.iter().filter_map(|(f, _)| *f).collect(); + assert_eq!( + asserted, + vec![pressure, depth, join], + "both sides and the edge are the complete proof" + ); + assert!(premises.iter().all(|(_, d)| d.is_none())); + Ok::<_, anyhow::Error>(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(&pool) + .await?; + run +} diff --git a/crates/utopia-store/tests/store/a_rule_computes_what_it_concludes.rs b/crates/utopia-store/tests/store/a_rule_computes_what_it_concludes.rs index 544dac0ac..6bdf00393 100644 --- a/crates/utopia-store/tests/store/a_rule_computes_what_it_concludes.rs +++ b/crates/utopia-store/tests/store/a_rule_computes_what_it_concludes.rs @@ -101,6 +101,7 @@ async fn attr(pool: &PgPool, f: &Fixture, predicate: Uuid, value: f64) -> anyhow fn present_revenue(f: &Fixture) -> Vec { vec![ConditionInput { group: 0, + side: "x".into(), predicate_id: f.revenue, op: "present".into(), operand: None, @@ -140,6 +141,7 @@ async fn a_computed_conclusion_lands_with_the_readings_it_read() -> anyhow::Resu Some(f.margin), None, Some(margin_expr(&f)), + None, &present_revenue(&f), ) .await?; @@ -236,6 +238,7 @@ async fn a_missing_reading_lands_nothing() -> anyhow::Result<()> { Some(f.margin), None, Some(margin_expr(&f)), + None, &present_revenue(&f), ) .await?; @@ -303,6 +306,7 @@ async fn a_broken_expression_is_refused_where_it_is_written() -> anyhow::Result< Some(f.margin), None, Some(expr), + None, &present_revenue(&f), ) .await; diff --git a/crates/utopia-store/tests/store/a_rule_concludes_a_type.rs b/crates/utopia-store/tests/store/a_rule_concludes_a_type.rs index 2af1087bb..8b57cafed 100644 --- a/crates/utopia-store/tests/store/a_rule_concludes_a_type.rs +++ b/crates/utopia-store/tests/store/a_rule_concludes_a_type.rs @@ -598,6 +598,7 @@ async fn changing_the_conclusion_retires_the_old_one() -> anyhow::Result<()> { predicate_id: Some(verdict), value: Some(serde_json::json!("含气")), expr: None, + join_predicate_id: None, }), ) .await?; @@ -865,6 +866,7 @@ async fn renaming_a_rule_uses_the_creation_name_limits_before_any_write() -> any let f = seed(&pool).await?; let conditions = [ConditionInput { group: 0, + side: "x".into(), predicate_id: f.thc, op: "gt".into(), operand: Some(serde_json::json!(5)), @@ -880,11 +882,13 @@ async fn renaming_a_rule_uses_the_creation_name_limits_before_any_write() -> any None, None, None, + None, &conditions, ) .await?; let changed = [ConditionInput { group: 9, + side: "x".into(), predicate_id: f.thc, op: "lt".into(), operand: Some(serde_json::json!(10)), @@ -895,6 +899,7 @@ async fn renaming_a_rule_uses_the_creation_name_limits_before_any_write() -> any predicate_id: Some(f.category), value: Some(serde_json::json!({"value":"changed"})), expr: None, + join_predicate_id: None, }; for invalid in [ String::new(), diff --git a/docs/decisions/0047-a-rule-may-conclude-a-relation.md b/docs/decisions/0047-a-rule-may-conclude-a-relation.md index 1f8444119..05c684624 100644 --- a/docs/decisions/0047-a-rule-may-conclude-a-relation.md +++ b/docs/decisions/0047-a-rule-may-conclude-a-relation.md @@ -1,6 +1,6 @@ # 0047 · A rule may conclude a relation -- **Status**: Proposed 2026-09-20 · nothing built · revises the edge exclusion stated in [0021](0021-a-rule-reads-attributes-and-concludes-a-type.md), and asks the question [0030](0030-a-rule-may-read-what-a-rule-concluded.md) parked as "nobody has asked it" +- **Status**: Implemented 2026-09-22 · pending review · revises the edge exclusion stated in [0021](0021-a-rule-reads-attributes-and-concludes-a-type.md), and asks the question [0030](0030-a-rule-may-read-what-a-rule-concluded.md) parked as "nobody has asked it" - **Written**: 2026-09-20 (conventions in the [README](README.md)) - **Related**: [0021](0021-a-rule-reads-attributes-and-concludes-a-type.md) built the rule and excluded an edge conclusion; [0030](0030-a-rule-may-read-what-a-rule-concluded.md) replaced that exclusion's acyclicity argument with a finiteness one on the value channel, and this record carries it to the edge channel; **[0032](0032-a-rule-computes-what-it-concludes.md) already decided that a rule may reach a value across one relation** and has not built it — this record depends on that loader rather than re-deciding it; [0002](0002-reasoning-engine.md) built the axiom fixed point and left R3 open; [0024](0024-the-world-axis-reaches-the-second.md) governs the precision of a derived bound; [0013](0013-a-source-should-hand-over-its-history.md) forbids reading a previous run's output, which stays forbidden. From #818. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2631abb91..0f70b4281 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -72,7 +72,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0044 | [The ontology is a view over what documents say](0044-the-ontology-is-a-view-over-what-documents-say.md) | Accepted · cut 1 built (#731, #735, #736, #741, #743–#745): extraction writes open statements, memory documents take the same path, the typed path is deleted, kind words bind to classes · cut 2 (alignment producing typed facts, identity profiles, the errata agent) not built · three layers: extraction writes an open graph in the documents' words (0 of 333 statements unstated in the prototype, against 4–9% of facts bound at write time), a small ontology proposed by an agent and approved by people, and a typed graph computed from the open graph on cached signatures · time mentions resolved against a document time context by code · identity across documents on deterministic evidence before the adjudicator · an errata agent reviews the typed graph | | 0045 | [A time mention is resolved against its document](0045-a-time-mention-is-resolved-against-its-document.md) | Accepted · cuts 1 and 2 built (#740): a document is dated from its own text, each mention is interpreted by the model and computed by code, upload time is used nowhere · cuts 3 and 4 (grades replace the confidence gate, re-resolution and the anchor queue) not built · a time expression is a mention with its words and place; the model returns shape, anchor, offset and granularity and code computes the interval; a document carries its own date, calendars and anchors across chunks, never its upload time; unresolved mentions wait for an anchor; timelines close on resolution grade instead of confidence | | 0046 | [The app surface is MCP](0046-the-app-surface-is-mcp.md) | Decided, with the refused design kept. Asked for an app center: applications built on this knowledge, mounted, run in a sandbox, handed to a team. The answer is that the surface already exists — a personal token carries identity and scope, ten read tools serve chat and MCP from one place, `as_of` reaches every graph read, and a read returns `structuredContent` with stable ledger identities — so a coding agent builds on this base today in its own platform, its own language and its own sandbox. Refused here because the layer an app would read is being replaced under it (typed facts now come only from alignment), because 0016 closes open seams before cutting new ones, and because a catalog, an execution boundary and quotas are three other products. The shape is kept with the four gates it would have to hold (runs as the caller, egress only through a declared action, a declared clock, the existing queue) and the dead ends: a container runtime (withdrawn the day it was written — WeKnora's skills are human-written and assume a shell, and they pay for it), a Wasm component runtime (better on every axis including the determinism re-parse needs, still not built because the reason is priority), a service identity per app, an app as a saved conversation. Reopened by a named customer who needs a button inside the product, by the type layer settling, or after 0034 | -| 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | Proposed 2026-09-20 · nothing built · A rule reads one entity and concludes about that same entity, so a threshold over a chain — a holding above 50% in a company that itself holds above 50% in another — cannot be written at all, and the query-time path walk that answers it produces no interval, no premises and nothing a queue can see. The conclusion becomes a **relation** between the subject and one entity reached across one declared relation, valid on the intersection of every premise interval including the join edge's. The concluded edge rejoins the pool `derive()` reads and the axiom pass runs once per round, coupling the two reasoners for the first time: 0021's cycle objection is answered with the **finiteness** argument [0030](0030-a-rule-may-read-what-a-rule-concluded.md) already put in place of acyclicity, rather than with a fixed ordering that would let a legitimate rule silently never fire. Reading a value across a hop is [0032](0032-a-rule-computes-what-it-concludes.md)'s decision, reused rather than re-decided. Negation, aggregation, a second hop and user-defined recursion stay out; the three caps in play are set by measurement in the PR that changes them | +| 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | Implemented 2026-09-22 · pending review · A rule reads one entity and concludes about that same entity, so a threshold over a chain — a holding above 50% in a company that itself holds above 50% in another — cannot be written at all, and the query-time path walk that answers it produces no interval, no premises and nothing a queue can see. The conclusion becomes a **relation** between the subject and one entity reached across one declared relation, valid on the intersection of every premise interval including the join edge's. The concluded edge rejoins the pool `derive()` reads and the axiom pass runs once per round, coupling the two reasoners for the first time: 0021's cycle objection is answered with the **finiteness** argument [0030](0030-a-rule-may-read-what-a-rule-concluded.md) already put in place of acyclicity, rather than with a fixed ordering that would let a legitimate rule silently never fire. Reading a value across a hop is [0032](0032-a-rule-computes-what-it-concludes.md)'s decision, reused rather than re-decided. Negation, aggregation, a second hop and user-defined recursion stay out; the existing caps stay in place because this cut changes none of them | | 0048 | [Provenance references stay inside the knowledge base](0048-provenance-references-stay-inside-the-knowledge-base.md) | Implemented in PR #832 (migration 0070) · a column foreign key proves the target exists, not that it is the same KB's — every reference an export can resolve gets a schema-level same-KB invariant: composite `(kb_id, ref)` foreign keys on the 26 edges whose row carries its own `kb_id` (same-table self-references deferred to commit), row triggers on the 13 whose kb authority is a parent row, `kb_id` immutability on every owned table, and a precondition scan that fails the migration closed on an already-cross-KB ledger · a catalog-derived guard keeps the 39 edges covered in the schema and, with #874, in export preflight · measured populate cost within noise; mechanism choice settled in #832 (discussion in issue #842) | | 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | Proposed · declaration policy pending; opt-in web draft only | | 0050 | [An action attempt keeps its identity and uncertain outcome](0050-an-action-attempt-keeps-its-identity-and-uncertain-outcome.md) | Proposed · durable execution identity and uncertain outcomes; no sender | diff --git a/migrations/0071_a_rule_joins_two_entities.sql b/migrations/0071_a_rule_joins_two_entities.sql new file mode 100644 index 000000000..5d06e5437 --- /dev/null +++ b/migrations/0071_a_rule_joins_two_entities.sql @@ -0,0 +1,58 @@ +-- 0047 · A rule may conclude a relation (#818). +-- +-- Until now a business rule could name only its subject. The join predicate is +-- the one declared edge X --join--> Y that brings a second entity into the body; +-- the relation conclusion then lands on that same pair. One hop keeps the +-- conclusion explainable and lets a second rule carry the path further. +ALTER TABLE attribute_rules + ADD COLUMN join_predicate_id UUID; + +ALTER TABLE attribute_rule_conditions + ADD COLUMN subject_side TEXT NOT NULL DEFAULT 'x' + CONSTRAINT attribute_rule_condition_subject_side_check + CHECK (subject_side IN ('x', 'y')); + +ALTER TABLE attribute_rules + ADD CONSTRAINT attribute_rules_join_predicate_same_kb + FOREIGN KEY (kb_id, join_predicate_id) + REFERENCES relation_types (kb_id, id) + ON DELETE CASCADE; + +ALTER TABLE attribute_rules + DROP CONSTRAINT attribute_rules_conclusion_check, + ADD CONSTRAINT attribute_rules_conclusion_check + CHECK (conclusion IN ('typing', 'attribute', 'computed', 'relation')); + +-- A relation needs both halves of the edge it creates. The older conclusions +-- remain single-subject and therefore keep the join predicate empty. +ALTER TABLE attribute_rules + DROP CONSTRAINT attribute_rule_conclusion_shape, + ADD CONSTRAINT attribute_rule_conclusion_shape CHECK ( + (conclusion = 'typing' + AND conclude_type_id IS NOT NULL + AND conclude_predicate_id IS NULL + AND conclude_value IS NULL + AND conclude_expr IS NULL + AND join_predicate_id IS NULL) + OR + (conclusion = 'attribute' + AND conclude_type_id IS NULL + AND conclude_predicate_id IS NOT NULL + AND conclude_value IS NOT NULL + AND conclude_expr IS NULL + AND join_predicate_id IS NULL) + OR + (conclusion = 'computed' + AND conclude_type_id IS NULL + AND conclude_predicate_id IS NOT NULL + AND conclude_value IS NULL + AND conclude_expr IS NOT NULL + AND join_predicate_id IS NULL) + OR + (conclusion = 'relation' + AND conclude_type_id IS NULL + AND conclude_predicate_id IS NOT NULL + AND conclude_value IS NULL + AND conclude_expr IS NULL + AND join_predicate_id IS NOT NULL) + ); diff --git a/web/src/api.ts b/web/src/api.ts index 783f7ac77..5fbc9da62 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -357,16 +357,19 @@ export interface RuleCondition { /** 数字 / [lo,hi] / 字符串数组;present 不带 */ operand?: unknown; predicate_label?: string; + /** x = rule subject (default); y = the entity reached by the one declared join */ + side?: "x" | "y"; } export interface RuleInput { name: string; description?: string; subject_type_id: string; - /** typing = 推出一个类;attribute = 推出一个属性值 */ - conclusion: "typing" | "attribute"; + /** typing = class; attribute = value; relation = an edge to the joined Y */ + conclusion: "typing" | "attribute" | "relation"; conclude_type_id?: string; conclude_predicate_id?: string; + join_predicate_id?: string; conclude_value?: unknown; conditions: RuleCondition[]; } @@ -379,12 +382,15 @@ export interface RuleMatch { concluded: string | null; valid_from: string | null; valid_to: string | null; + object_id?: string | null; + object_entity?: string | null; + relation_predicate?: string | null; /** 「全烃 = 12.3」这种可读形态,按前提顺序 */ premises: string[]; } -export interface BusinessRule extends Omit { - conclusion: "typing" | "attribute" | "computed"; +export interface BusinessRule extends Omit { + conclusion: "typing" | "attribute" | "computed" | "relation"; /** Raw server tree; unsupported nodes must remain read-only. */ conclude_expr?: unknown; id: string; @@ -392,6 +398,8 @@ export interface BusinessRule extends Omit { subject_label: string; conclude_type_label: string | null; conclude_predicate_label: string | null; + join_predicate_id?: string | null; + join_predicate_label?: string | null; /** 此刻凭它成立的结论条数 */ derived_count: number; /** 上次跑的时候有几个实体的读数组合没展开完。**大于零就意味着少推了** */ @@ -1953,9 +1961,10 @@ export const api = { enabled?: boolean; conditions?: RuleCondition[]; /** 结论整组替换:三格互相定义,只改一格会留下半截状态 */ - conclusion?: "typing" | "attribute"; + conclusion?: "typing" | "attribute" | "relation"; conclude_type_id?: string; conclude_predicate_id?: string; + join_predicate_id?: string; conclude_value?: unknown; }, ) => diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e06f4f731..bd2fd0330 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1306,6 +1306,11 @@ export const en = { ruleConcludes: "Concludes", ruleConcludesTyping: "the class", ruleConcludesAttribute: "the attribute", + ruleConcludesRelation: "the relation", + ruleConcludesRelationText: (join: string, conclude: string): string => + `${conclude} from X through ${join}`, + ruleSideX: "X", + ruleSideY: "Y", /* 从前是「当以下全部成立」。**一条规则现在可以写第二种情况**,那句话就 不再是真的——标签退回一个「当」,全不全由下面那句说明交代 */ ruleConditions: "When", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 77ea854ae..f5f370aa4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1170,6 +1170,11 @@ export const zh: Strings = { ruleConcludes: "得出", ruleConcludesTyping: "这个类", ruleConcludesAttribute: "这个属性", + ruleConcludesRelation: "这条关系", + ruleConcludesRelationText: (join: string, conclude: string): string => + `经「${join}」从 X 到「${conclude}」`, + ruleSideX: "X", + ruleSideY: "Y", ruleConditions: "当满足", ruleConditionsHint: "一块里的条件要同时成立;再加一块就是另一种情况,任意一块成立即可。", ruleAddCondition: "加一个条件", diff --git a/web/src/pages/Ontology.tsx b/web/src/pages/Ontology.tsx index 57cc10947..3df699b7d 100644 --- a/web/src/pages/Ontology.tsx +++ b/web/src/pages/Ontology.tsx @@ -519,6 +519,7 @@ export function Ontology() { focusId={sel.kind === "rules" ? sel.focusId : undefined} classes={entity_types} attributes={relation_types.filter((r) => r.kind === "attribute")} + relations={relation_types.filter((r) => r.kind === "relation")} onError={onError} /> diff --git a/web/src/pages/RulesPanel.tsx b/web/src/pages/RulesPanel.tsx index 40845d168..899093b3b 100644 --- a/web/src/pages/RulesPanel.tsx +++ b/web/src/pages/RulesPanel.tsx @@ -63,13 +63,15 @@ const operandKind = (op: string) => /** 一条规则可以被搜到的全部文本。**判据也算**——「哪条规则用到了 Clearance」 是找规则最常见的问法,只搜名字的话得先记住自己当初叫它什么 */ -function searchText(r: BusinessRule, attributes: RelationTypeView[]): string { +function searchText(r: BusinessRule, attributes: RelationTypeView[], relations: RelationTypeView[]): string { return [ r.name, r.description ?? "", r.subject_label, r.conclude_type_label ?? "", r.conclude_predicate_label ?? "", + r.join_predicate_label ?? "", + ...r.conditions.map((c) => c.side), r.conclusion === "computed" ? expressionText(r.conclude_expr, attributes, S.ontology.ruleUnknownExpression) : "", ...r.conditions.map( (c) => `${c.predicate_label} ${operandText(c.op, c.operand, attributes)}`, @@ -122,9 +124,13 @@ function byGroup(conditions: T[]): T[][] { .map((n) => conditions.filter((c) => g(c) === n)); } +/** Type views make both dropdowns readable, so the API only has to carry ids. */ +const labelOptions = (items: RelationTypeView[]) => + items.map((item) => ({ value: item.id, label: item.label })); + /** 表单里的一行条件。**文本原样留着**——解析放到保存那一刻,否则打字打到 一半的「1」会被当成写完的数 */ -type Row = { predicate_id: string; op: string; text: string }; +type Row = { side: "x" | "y"; predicate_id: string; op: string; text: string }; type Draft = { /** 改的是哪一条;新建时为 null。**同一份草稿两种用途**——两套表单会漂移 */ @@ -132,9 +138,10 @@ type Draft = { name: string; description: string; subject_type_id: string; - conclusion: "typing" | "attribute"; + conclusion: "typing" | "attribute" | "relation"; conclude_type_id: string; conclude_predicate_id: string; + join_predicate_id: string; conclude_value: string; /** 一块是一个合取,块之间是析取。**空数组只允许出现在唯一一块上**—— 那是「还没写条件」,不是「无条件成立」(空合取恒真,会归进整个类) */ @@ -144,6 +151,7 @@ type Draft = { const emptyDraft = ( classes: EntityTypeView[], attrs: RelationTypeView[], + relations: RelationTypeView[], ): Draft => ({ id: null, name: "", @@ -152,8 +160,12 @@ const emptyDraft = ( conclusion: "typing", conclude_type_id: classes[0]?.id ?? "", conclude_predicate_id: attrs[0]?.id ?? "", + join_predicate_id: relations[0]?.id ?? "", conclude_value: "", - groups: attrs[0] ? [[{ predicate_id: attrs[0].id, op: "gt", text: "" }]] : [[]], + groups: + attrs[0] + ? [[{ side: "x", predicate_id: attrs[0].id, op: "gt", text: "" }]] + : [[]], }); /** 已有规则 → 草稿。**回读要与写入是同一套形状**,否则编辑一次就变形。 */ @@ -161,6 +173,7 @@ function draftOf(r: BusinessRule): Draft { if (metadataOnly(r)) throw new Error(S.ontology.ruleExpressionReadOnly); const groups = byGroup(r.conditions).map((g) => g.map((c) => ({ + side: c.side ?? "x", predicate_id: c.predicate_id, op: c.op, text: operandText(c.op, c.operand), @@ -174,6 +187,7 @@ function draftOf(r: BusinessRule): Draft { conclusion: r.conclusion as Draft["conclusion"], conclude_type_id: r.conclude_type_id ?? "", conclude_predicate_id: r.conclude_predicate_id ?? "", + join_predicate_id: r.join_predicate_id ?? "", conclude_value: typeof r.conclude_value === "string" ? r.conclude_value @@ -203,7 +217,13 @@ function Matches({ kbId, ruleId }: { kbId: string; ruleId: string }) {
{m.entity} - → {m.concluded} + {m.object_entity ? ( + + → {m.relation_predicate ?? m.concluded} {m.object_entity} + + ) : ( + → {m.concluded} + )} {/* 同一个实体会因为不同时段的读数出现好几次,写出这一段才不像重复 */} {m.valid_from && ( @@ -236,6 +256,7 @@ export function RulesPanel({ focusId, classes, attributes, + relations, onError, }: { kbId: string; @@ -244,6 +265,8 @@ export function RulesPanel({ classes: EntityTypeView[]; /** kind='attribute' 的谓词——规则只读实体自己的字面值 */ attributes: RelationTypeView[]; + /** kind='relation' 的谓词——一条 joined rule 只走其中一条边 */ + relations: RelationTypeView[]; onError: (e: unknown) => void; }) { const qc = useQueryClient(); @@ -286,7 +309,7 @@ export function RulesPanel({ if (operandKind(c.op) !== "none" && operand === undefined) { throw new Error(S.ontology.ruleNeedsCondition); } - return { group: gi, predicate_id: c.predicate_id, op: c.op, operand }; + return { group: gi, side: c.side, predicate_id: c.predicate_id, op: c.op, operand }; }), ); if (!conditions.length) throw new Error(S.ontology.ruleNeedsCondition); @@ -295,9 +318,11 @@ export function RulesPanel({ conclude_type_id: d.conclusion === "typing" ? d.conclude_type_id : undefined, conclude_predicate_id: - d.conclusion === "attribute" ? d.conclude_predicate_id : undefined, + d.conclusion === "typing" ? undefined : d.conclude_predicate_id, conclude_value: d.conclusion === "attribute" ? d.conclude_value : undefined, + join_predicate_id: + d.conclusion === "relation" ? d.join_predicate_id : undefined, }; // 改一条已有的规则走 PATCH,主类不动——换主类等于换一条规则, // 那时候删了重写比原地改诚实 @@ -361,7 +386,7 @@ export function RulesPanel({ }; const needle = filter.trim().toLowerCase(); const list = needle - ? all.filter((r) => searchText(r, attributes).includes(needle)) + ? all.filter((r) => searchText(r, attributes, relations).includes(needle)) : all; /** 命中列表看的是哪一条。一次一条——两份长列表并排读不了 */ const opening = list.find((r) => r.id === opened) ?? null; @@ -414,7 +439,7 @@ export function RulesPanel({
@@ -850,6 +928,15 @@ function RuleConclusion({ rule: r, attributes }: { rule: BusinessRule; attribute if (r.conclusion === "typing") return <>{r.conclude_type_label}; if (r.conclusion === "computed") return <>{r.conclude_predicate_label} = {expressionText(r.conclude_expr, attributes, S.ontology.ruleUnknownExpression)}; if (r.conclusion === "attribute") return <>{r.conclude_predicate_label} = {JSON.stringify(r.conclude_value)}; + if (r.conclusion === "relation") + return ( + <> + {S.ontology.ruleConcludesRelationText( + r.join_predicate_label ?? r.join_predicate_id ?? "", + r.conclude_predicate_label ?? r.conclude_predicate_id ?? "", + )} + + ); return <>{S.ontology.ruleUnknownExpression}; } From 61bcea320c5aea8b9dd73017d2989e73f1fea386 Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Fri, 25 Sep 2026 00:29:13 +0800 Subject: [PATCH 2/2] Let the review queue see a refused rule relation, restore the derived count, and bucket the join Review fixes on #861, applied as maintainer edits. run() and materialize() now share one resolve() step: asserted edges plus the surviving rule-concluded relation edges form the pool, derive() runs over it once per round, and contradictions() sees every relation candidate. A candidate that loses leaves the pool but stays in the candidate list, so the queue row exists for it and says which business rule produced it (`rule: business_rule`, `attribute_rule_id`); the conclusions that stood on it retire with it, and a refused key is not retried, so the fixed point still ends. The queue's key and foreign key fall back to the last asserted premise when the chain runs through a provisional edge. `Rule::Business` names such a candidate instead of borrowing `Transitive`, so nothing positional keeps a rule-concluded edge out of the axiom persistence loop. `derive_with_blocked` goes: the pool is rebuilt each round, so a refused edge is simply not in it. `DeriveReport.derived` is again what the engine produced (the last round's axiom derivations and relation candidates, plus the rules' distinct conclusions), which `a_contradiction_points_upstream` pins. `joined_evaluate` buckets the join edges by subject: scanning all edges per X was quadratic in pairs, 5.9 s for 100,000 pairs against 82 ms for 10,000; bucketed it is 105 ms. Decision 4's numbers are in the record and the PR; the caps stay. Migration 0071 keeps its number, CURRENT_SCHEMA_VERSION is the file count (75), and the two tests dev gained since the branch use the new condition side and join argument. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Wayland Yang --- crates/utopia-reason/src/derive.rs | 23 +- crates/utopia-reason/src/rules.rs | 22 +- .../src/api/sources_cleanup_tests.rs | 2 + crates/utopia-store/src/reasoning.rs | 622 +++++++++++------- .../tests/a_rule_concludes_a_relation.rs | 146 ++++ .../store/a_plan_step_follows_its_premise.rs | 2 + .../0047-a-rule-may-conclude-a-relation.md | 4 +- docs/decisions/README.md | 2 +- 8 files changed, 561 insertions(+), 262 deletions(-) diff --git a/crates/utopia-reason/src/derive.rs b/crates/utopia-reason/src/derive.rs index ea473e43e..e853e7d9a 100644 --- a/crates/utopia-reason/src/derive.rs +++ b/crates/utopia-reason/src/derive.rs @@ -42,6 +42,10 @@ pub enum Rule { Inverse, /// `A p B` ∧ `p ⊑ q` ⟹ `A q B`。主宾不动,只升谓词 SubProperty, + /// 一条业务规则推出的关系边(0047)。不是公理:`derive()` 从不产出它, + /// 它只作为**候选**进矛盾检查,让撞上断言或别的派生时能像公理派生一样被报出来。 + /// 触发它的规则行在 `attribute_rules` 里,不在公理规则表里 + Business, } impl Rule { @@ -51,6 +55,7 @@ impl Rule { Rule::Symmetric => "symmetric", Rule::Inverse => "inverse", Rule::SubProperty => "sub_property", + Rule::Business => "business_rule", } } } @@ -156,18 +161,6 @@ type Triple = (Uuid, Uuid, Uuid); /// 三条一跳规则(对称/逆/子属性)与传递放在同一轮里,因为它们互为输入—— /// 逆推出来的边可能让某条传递链接得上,反之亦然。 pub fn derive(edges: &[TimedEdge], axioms: &HashMap) -> Derivation { - derive_with_blocked(edges, axioms, &HashSet::new()) -} - -/// Derive while refusing the named conclusions. -/// -/// A relation that lost a contradiction check is still visible as an input for -/// that check, but it cannot become a premise in the next fixed-point round. -pub fn derive_with_blocked( - edges: &[TimedEdge], - axioms: &HashMap, - blocked: &HashSet<(Uuid, Uuid, Uuid)>, -) -> Derivation { let mut out = Derivation::default(); // 断言过的三元组。**派生撞上它就让路**——asserted > derived 是硬性的 @@ -244,9 +237,6 @@ pub fn derive_with_blocked( hops.push(((sup, subj, obj), Rule::SubProperty)); } for (t, rule) in hops { - if blocked.contains(&t) { - continue; - } if emit( t, pred, @@ -276,9 +266,6 @@ pub fn derive_with_blocked( if subj == c { continue; } - if blocked.contains(&(pred, subj, c)) { - continue; - } let Some((nf, nt)) = overlap((acc.from, acc.to), (from, to)) else { continue; }; diff --git a/crates/utopia-reason/src/rules.rs b/crates/utopia-reason/src/rules.rs index 062bd251e..198ed9d44 100644 --- a/crates/utopia-reason/src/rules.rs +++ b/crates/utopia-reason/src/rules.rs @@ -501,26 +501,24 @@ fn joined_evaluate( pool_by_subject.entry(f.subject).or_default().push(f); } - let matching: Vec<&RuleEdge> = edges - .iter() - .filter(|e| e.predicate == join_predicate) - .collect(); - let x_subjects: Vec = { - let mut xs: Vec = matching.iter().map(|e| e.subject).collect(); - xs.sort_unstable(); - xs.dedup(); - xs - }; + // 连接边按 X 分桶,一次扫完:对每个 X 再去全部边里找它的,是 X 数乘边数—— + // 十万对上量出来是 5.9 s 对 1 万对的 82 ms,分桶之后随对数线性 + let mut edges_by_x: HashMap> = HashMap::new(); + for e in edges.iter().filter(|e| e.predicate == join_predicate) { + edges_by_x.entry(e.subject).or_default().push(e); + } + let mut x_subjects: Vec = edges_by_x.keys().copied().collect(); + x_subjects.sort_unstable(); + let groups = group_conditions(&rule.conditions); for x in x_subjects { let x_facts = x_by_subject.get(&x).map(Vec::as_slice).unwrap_or_default(); - for edge in matching.iter().filter(|e| e.subject == x) { + for edge in &edges_by_x[&x] { let y = edge.object; let y_facts = pool_by_subject .get(&y) .map(Vec::as_slice) .unwrap_or_default(); - let groups = group_conditions(&rule.conditions); // 同一对上的多个组可能推出同一结论。留先到的组作证明,与单实体 // 规则的去重规则一致 let mut seen: Vec<(Option, Option, Option)> = Vec::new(); diff --git a/crates/utopia-server/src/api/sources_cleanup_tests.rs b/crates/utopia-server/src/api/sources_cleanup_tests.rs index 5a2e7f7bf..5ba6ff21b 100644 --- a/crates/utopia-server/src/api/sources_cleanup_tests.rs +++ b/crates/utopia-server/src/api/sources_cleanup_tests.rs @@ -105,11 +105,13 @@ impl Fixture { None, None, None, + None, &[ConditionInput { group: 0, predicate_id: location, op: "in".into(), operand: Some(json!(["desk"])), + side: "x".into(), }], ) .await?; diff --git a/crates/utopia-store/src/reasoning.rs b/crates/utopia-store/src/reasoning.rs index 908a10e8d..ee59dd385 100644 --- a/crates/utopia-store/src/reasoning.rs +++ b/crates/utopia-store/src/reasoning.rs @@ -216,8 +216,22 @@ pub async fn record_signature_breaks( /// 跑一遍检查,把结果落库。 pub async fn run(pool: &PgPool, kb_id: Uuid) -> AppResult { - let (timed, spans, _) = timed_edges(pool, kb_id).await?; - let axioms = axioms(pool, kb_id).await?; + // 与 `materialize` 同一次求解:候选里有规则推出的关系边,被拦下的也在—— + // 队列报的正是那边拦下的(0017,0047 决定 3) + let Resolved { + edges: timed, + spans, + axioms, + asserted_ids, + checked, + .. + } = resolve(pool, kb_id).await?; + let Checked { + candidates: derivation, + candidate_rule, + clashes, + .. + } = checked; // 带着区间查:互斥的三类只在同时成立时才算(#634)。从前这里把区间剥掉再查, // 每一次调薪、每一次换负责人都进了 Review let checked = check_all(&timed, &axioms); @@ -236,18 +250,23 @@ pub async fn run(pool: &PgPool, kb_id: Uuid) -> AppResult { // 第六类(0017):推出来却落不了地的派生。与 `materialize` 用同一个函数算, // 所以这里报的正是那边拦下的——两边各算一套的话,队列会跟图对不上 - let derivation = utopia_reason::derive::derive(&timed, &axioms); - let clashes = utopia_reason::derive::contradictions(&derivation, &timed, &axioms, &spans); let names = names_for(pool, &derivation, &clashes).await?; let mut details: HashMap<(Uuid, Uuid), serde_json::Value> = HashMap::new(); let mut per_pred: HashMap = HashMap::new(); let mut contradictions_capped = 0usize; for c in &clashes.with_assertions { let d = &derivation.facts[c.derived]; - let Some(&last) = d.premises.last() else { + // 键与外键都要 `facts` 里的行。链经过规则推出的关系边时,最后一条前提是 + // 它的临时 id——每轮都不同——退到链上最后一条断言;自环的 `against` 也是它 + let Some(&last) = d.premises.iter().rev().find(|p| asserted_ids.contains(p)) else { continue; }; - let key = (c.against, last); + let against = if asserted_ids.contains(&c.against) { + c.against + } else { + last + }; + let key = (against, last); if details.contains_key(&key) { continue; } @@ -263,6 +282,8 @@ pub async fn run(pool: &PgPool, kb_id: Uuid) -> AppResult { json!({ "axiom": c.axiom.as_str(), "rule": d.rule.as_str(), + // 规则推出的关系边:是哪条业务规则(0047)。公理派生没有 + "attribute_rule_id": candidate_rule.get(&c.derived), "via": d.via, "via_label": names.predicate(d.via), "subject_id": d.subject, @@ -278,7 +299,7 @@ pub async fn run(pool: &PgPool, kb_id: Uuid) -> AppResult { ); violations.push(Violation { kind: Kind::DerivedContradiction, - left: c.against, + left: against, right: last, path: d.premises.clone(), }); @@ -1196,45 +1217,6 @@ type DerivedKey = ( type AssertedEdges = HashMap<(Uuid, Uuid, Uuid), Vec<(Option, Option)>>; -fn merge_axiom_derivation( - derivation: &utopia_reason::derive::Derivation, - blocked: &HashSet, - spans: &HashMap, Option)>, - rules: &HashMap<(Uuid, RuleKind), Uuid>, - wanted: &mut HashMap, -) -> (usize, usize) { - let before = wanted.len(); - let mut unruled = 0usize; - for (i, d) in derivation.facts.iter().enumerate() { - if blocked.contains(&i) { - continue; - } - let Some((from, to)) = utopia_reason::derive::validity(&d.premises, spans) else { - continue; - }; - // **按 `via` 查,不是 `predicate`。** 跨谓词的规则里,派生出来的谓词 - // 是另一个;落到账本前必须先找到触发它的规则行。 - let Some(&rule_id) = rules.get(&(d.via, d.rule.as_str())) else { - unruled += 1; - continue; - }; - wanted - .entry((d.subject, d.predicate, Some(d.object), None, from, to)) - .or_insert(Wanted { - subject: d.subject, - predicate: d.predicate, - object_id: Some(d.object), - object_value: None, - from, - to, - premises: d.premises.clone(), - rule_id: Some(rule_id), - attribute_rule_id: None, - }); - } - (wanted.len() - before, unruled) -} - /// 这一轮要落库的一条派生。公理推出来的与规则推出来的在这里合流—— /// **合流是必须的**:陈旧行的对账扫的是整张表,两趟各做各的 diff 会把对方的 /// 行每轮都判成陈旧作废掉。 @@ -1754,19 +1736,78 @@ async fn attribute_facts( /// /// **调用方负责检查 `materialize_inferences` 开关。** 这一层不判——它也被 /// 「预览一下会推出什么」那条路用,而预览不该受开关约束。 -pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult { - let ax = axioms(pool, kb_id).await?; - let rules = compile_rules(pool, kb_id, &ax).await?; - let (edges, mut spans, mut meta) = timed_edges(pool, kb_id).await?; +/// 一条业务规则推出的关系边在不动点里的身份(0047 决定 3)。 +/// +/// 站得住的:以临时 id 进池子当一条普通带区间的边,并作为候选进矛盾检查。 +/// 被拦下的(`refused`):退出池子、退出 `wanted`,但**留在候选里**——审核队列 +/// 报的正是它为什么没落地;两边各算一套的话,队列会跟图对不上(0017) +struct RelationCandidate { + edge: utopia_reason::rules::RuleEdge, + key: DerivedKey, + premises: Vec, + rule_id: Uuid, + refused: bool, +} + +/// 一遍检查的产出:池子上的公理派生 + 规则推出的关系候选,和它们撞了什么。 +struct Checked { + /// 前 `axiom_count` 条是池子上的公理派生,其后是关系候选(站得住的与被拦下的都在, + /// 标签 `Rule::Business`) + candidates: Derivation, + axiom_count: usize, + /// 候选下标 → 推出它的业务规则。公理派生没有 + candidate_rule: HashMap, + clashes: Contradictions, + /// 不落地的候选下标:撞上断言且没被认可并存的,和撞上别的派生的 + blocked: HashSet, +} - let derivation = utopia_reason::derive::derive(&edges, &ax); +/// 在「断言边 + 站得住的关系边」的池子上跑一遍公理推导,再拿公理量一遍候选。 +/// +/// 断言那一侧只给断言:关系边是派生,它与别的派生(含经它推出的公理派生)互撞 +/// 走派生之间那一路,于是 `against` 始终是 `facts` 里的一行——审核队列的外键 +/// 指的就是那张表 +fn check( + edges: &[TimedEdge], + relation: &[RelationCandidate], + axioms: &HashMap, + spans: &HashMap, Option)>, + accepted: &HashSet<(Uuid, Uuid, Uuid, Uuid)>, +) -> Checked { + let mut timed: Vec = edges.to_vec(); + timed.extend(relation.iter().filter(|r| !r.refused).map(|r| { + let (from, to) = spans.get(&r.edge.id).copied().unwrap_or_default(); + TimedEdge { + edge: Edge { + fact: r.edge.id, + predicate: r.edge.predicate, + subject: r.edge.subject, + object: r.edge.object, + }, + from, + to, + } + })); + let mut candidates = utopia_reason::derive::derive(&timed, axioms); + let axiom_count = candidates.facts.len(); + let mut candidate_rule: HashMap = HashMap::new(); + for r in relation { + candidate_rule.insert(candidates.facts.len(), r.rule_id); + candidates.facts.push(utopia_reason::derive::Derived { + predicate: r.edge.predicate, + via: r.edge.predicate, + subject: r.edge.subject, + object: r.edge.object, + rule: utopia_reason::derive::Rule::Business, + premises: r.premises.clone(), + }); + } + let clashes = utopia_reason::derive::contradictions(&candidates, edges, axioms, spans); // asserted > derived 是硬性的(0002):撞上断言的派生不落地。人认可过并存的 // 除外;派生之间互撞的两边都不落,认可与否只影响报不报(0017) - let clashes = utopia_reason::derive::contradictions(&derivation, &edges, &ax, &spans); - let accepted = accepted_clashes(pool, kb_id).await?; let mut blocked: HashSet = HashSet::new(); for c in &clashes.with_assertions { - let d = &derivation.facts[c.derived]; + let d = &candidates.facts[c.derived]; if !accepted.contains(&(d.subject, d.predicate, d.object, c.against)) { blocked.insert(c.derived); } @@ -1777,43 +1818,143 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult blocked.insert(*j); } } - let mut blocked_triples: HashSet<(Uuid, Uuid, Uuid)> = blocked - .iter() - .map(|&i| { - let d = &derivation.facts[i]; - (d.subject, d.predicate, d.object) - }) - .collect(); + Checked { + candidates, + axiom_count, + candidate_rule, + clashes, + blocked, + } +} - let mut report = DeriveReport { - rules: rules.len(), - edges: edges.len(), - derived: 0, - capped: derivation.capped.len(), - blocked: blocked.len(), - ..Default::default() - }; +/// 被拦下的关系边退出去之后,站在它上面的结论一起退场:读了它的规则结论、再站在 +/// 那些结论上的结论,直到没有新的为止——一条派生随前提失效(0002),在不动点里 +/// 也成立。前提没了的关系候选不是被拦下的,是推不出来了:整个退出,候选也不留 +fn retire( + seed: HashSet, + wanted: &mut HashMap, + provisional: &mut HashMap, + fact_pool: &mut Vec, + relation: &mut Vec, +) { + let mut gone = seed; + loop { + let dropped: Vec = wanted + .iter() + .filter(|(_, w)| w.premises.iter().any(|p| gone.contains(p))) + .map(|(k, _)| k.clone()) + .collect(); + if dropped.is_empty() { + break; + } + for key in dropped { + wanted.remove(&key); + if let Some(prov) = provisional.remove(&key) { + gone.insert(prov); + } + } + } + fact_pool.retain(|f| !gone.contains(&f.id)); + relation.retain(|r| r.refused || !gone.contains(&r.edge.id)); +} - let mut wanted: HashMap = HashMap::new(); - let (initial_axioms, unruled) = - merge_axiom_derivation(&derivation, &blocked, &spans, &rules, &mut wanted); - report.derived += initial_axioms; - report.unruled += unruled; +/// 把这一遍检查拦下的关系候选退出池子。回有没有退出的:退了就得再检查一遍, +/// 池子变了 +fn refuse_blocked( + checked: &Checked, + relation: &mut Vec, + wanted: &mut HashMap, + provisional: &mut HashMap, + fact_pool: &mut Vec, +) -> bool { + let mut retired: HashSet = HashSet::new(); + for &i in &checked.blocked { + let Some(r) = i + .checked_sub(checked.axiom_count) + .and_then(|k| relation.get_mut(k)) + else { + continue; + }; + if r.refused { + continue; + } + r.refused = true; + retired.insert(r.edge.id); + wanted.remove(&r.key); + provisional.remove(&r.key); + } + if retired.is_empty() { + return false; + } + retire(retired, wanted, provisional, fact_pool, relation); + true +} - // 第二趟:属性事实上的业务规则(0021)。**并进同一个 `wanted`**—— - // 下面的陈旧对账扫的是整张 `derived_facts`,两趟各做各的 diff 会把对方 - // 落的行每一轮都判成陈旧 +/// `run()`(审核队列)与 `materialize()`(落库)共用的一次求解。 +/// +/// 输入只有断言(0013);输出是最后一轮的池子上的公理派生、规则的结论,和被拦下的 +/// 候选——两边从同一份取,队列才跟图对得上(0017)。规则推出的关系边进池子当一条 +/// 普通的边,公理推导每轮重跑一遍(0047 决定 3);一条被拦下的关系边退出池子而留在 +/// 候选里,站在它上面的结论随它退场 +struct Resolved { + /// 断言的边 + edges: Vec, + spans: HashMap, Option)>, + meta: HashMap, + axioms: HashMap, + /// 公理规则行:(声明所在的谓词, 种类) → 规则 id + rules: HashMap<(Uuid, RuleKind), Uuid>, + /// `facts` 里的行:边与属性事实。审核队列的键与外键只认这些 + asserted_ids: HashSet, + /// 最后一遍检查 + checked: Checked, + /// 规则的结论:属性的,和站得住的关系 + wanted: HashMap, + /// 一条规则结论的临时 id → 它最后落在哪一行。链上的前提指的是前者, + /// `fact_derivations` 要存的是后者(0030)。键是派生键,值是临时 id + provisional: HashMap, + loaded: LoadedRules, + capped_by_rule: HashMap, + rounds: usize, + rule_rounds_capped: bool, +} + +async fn resolve(pool: &PgPool, kb_id: Uuid) -> AppResult { + let axioms = axioms(pool, kb_id).await?; + let rules = compile_rules(pool, kb_id, &axioms).await?; + let (edges, mut spans, mut meta) = timed_edges(pool, kb_id).await?; + let accepted = accepted_clashes(pool, kb_id).await?; let loaded = attribute_rules(pool, kb_id).await?; - report.attribute_rules = loaded.rules.len(); - // 一条规则结论的临时 id → 它最后落在哪一行。链上的前提指的是前者, - // `fact_derivations` 要存的是后者(0030)。键是派生键,值是临时 id + let mut asserted_ids: HashSet = edges.iter().map(|e| e.edge.fact).collect(); + + let mut wanted: HashMap = HashMap::new(); let mut provisional: HashMap = HashMap::new(); + let mut relation: Vec = Vec::new(); + let mut capped_by_rule: HashMap = HashMap::new(); + let mut rounds = 0usize; + let mut checked = check(&edges, &relation, &axioms, &spans, &accepted); + + // 第二趟:属性事实上的业务规则(0021)。结论**并进同一个 `wanted`**—— + // 落库那边的陈旧对账扫的是整张 `derived_facts`,两趟各做各的 diff 会把对方 + // 落的行每一轮都判成陈旧 if !loaded.rules.is_empty() { let (asserted, attr_spans, attr_meta, type_of) = attribute_facts(pool, kb_id).await?; + asserted_ids.extend(asserted.iter().map(|f| f.id)); // 前提的精度与置信度:落地那一段与不动点这一段共用,所以两份 meta 先合起来; // 区间也要——认出派生的哪一端是被前提的锚点顶上来的,靠的就是它 meta.extend(attr_meta); spans.extend(attr_spans); + // 断言边上公理已经推出的键:规则再推出同一条关系时不另立一行——与属性结论 + // 「上一轮已经推出过同一条」同一条规矩 + let axiom_keys: HashSet = checked + .candidates + .facts + .iter() + .filter_map(|d| { + let (from, to) = utopia_reason::derive::validity(&d.premises, &spans)?; + Some((d.subject, d.predicate, Some(d.object), None, from, to)) + }) + .collect(); let edge_pool: Vec = edges .iter() .map(|e| utopia_reason::rules::RuleEdge { @@ -1823,9 +1964,6 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult object: e.edge.object, }) .collect(); - let mut relation_edges: Vec = Vec::new(); - let mut relation_keys: Vec = Vec::new(); - let mut relation_candidates: HashMap = HashMap::new(); let asserted_edges: AssertedEdges = edges .iter() .map(|e| { @@ -1851,116 +1989,31 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult // 每个实体被**推**出来的类。断言的类在 `type_of` 里,两者进规则的方式 // 不一样:断言的类没有区间,是个筛子;推出来的类有区间,得当条件 let mut derived_types: HashMap> = HashMap::new(); - let mut capped_by_rule: HashMap = HashMap::new(); - let mut rounds = 0usize; + // 上一轮加了关系边,池子还没过检查 + let mut dirty = false; for _ in 0..utopia_reason::MAX_DEPTH { rounds += 1; - // Rule edges have provisional ids in `spans`. They enter the axiom - // pool as ordinary timed edges, while the derivation and clash check - // also see them as candidate conclusions for this round. - let mut timed: Vec = edges.clone(); - timed.extend(relation_edges.iter().map(|e| { - let (from, to) = spans.get(&e.id).copied().unwrap_or_default(); - utopia_reason::derive::TimedEdge { - edge: utopia_reason::Edge { - fact: e.id, - predicate: e.predicate, - subject: e.subject, - object: e.object, - }, - from, - to, - } - })); - let derivation = - utopia_reason::derive::derive_with_blocked(&timed, &ax, &blocked_triples); - let mut candidates = derivation.facts.clone(); - for (edge, key) in relation_edges.iter().zip(&relation_keys) { - let wanted = relation_candidates - .get(key) - .expect("relation candidate exists"); - candidates.push(utopia_reason::derive::Derived { - predicate: edge.predicate, - via: edge.predicate, - subject: edge.subject, - object: edge.object, - // The axiom enum cannot name a business rule. This value is - // only used to group clash reports; blocked indexes below - // map back to the relation candidate before persistence. - rule: utopia_reason::derive::Rule::Transitive, - premises: wanted.premises.clone(), - }); - } - let candidate_derivation = utopia_reason::derive::Derivation { - facts: candidates, - capped: derivation.capped.clone(), - }; - let clashes = - utopia_reason::derive::contradictions(&candidate_derivation, &timed, &ax, &spans); - let mut blocked = clashes.blocked(); - blocked.retain(|&i| { - if let Some(c) = clashes.with_assertions.iter().find(|c| c.derived == i) { - let d = &candidate_derivation.facts[i]; - return !accepted.contains(&(d.subject, d.predicate, d.object, c.against)); - } - true - }); - blocked_triples = blocked - .iter() - .map(|&i| { - let d = &candidate_derivation.facts[i]; - (d.subject, d.predicate, d.object) - }) - .collect(); - let axiom_fact_count = derivation.facts.len(); - let blocked_relations: HashSet = blocked - .iter() - .filter(|&&i| i >= axiom_fact_count) - .map(|i| i - axiom_fact_count) - .collect(); - if !blocked_relations.is_empty() { - let mut edges = Vec::new(); - let mut keys = Vec::new(); - let mut candidates = HashMap::new(); - for (i, (edge, key)) in relation_edges - .iter() - .cloned() - .zip(relation_keys.iter().cloned()) - .enumerate() - { - if blocked_relations.contains(&i) { - continue; - } - let wanted = relation_candidates.get(&key).cloned().expect("candidate"); - edges.push(edge); - keys.push(key.clone()); - candidates.insert(key, wanted); - } - let blocked_keys: HashSet = blocked_relations - .iter() - .filter_map(|&i| relation_keys.get(i)) - .cloned() - .collect(); - relation_edges = edges; - relation_keys = keys; - relation_candidates = candidates; - for key in &blocked_keys { - provisional.remove(key); - wanted.remove(key); - } + // 规则推出的关系边先过公理与矛盾检查(0047 决定 3):站得住的这一轮起 + // 当一条普通的边,被拦下的退出池子。退了池子就变了,再查一遍,直到稳住 + let mut refused_now = false; + while dirty { + checked = check(&edges, &relation, &axioms, &spans, &accepted); + dirty = refuse_blocked( + &checked, + &mut relation, + &mut wanted, + &mut provisional, + &mut fact_pool, + ); + refused_now |= dirty; } - wanted.retain(|key, _| { - key.2 - .is_none_or(|object| !blocked_triples.contains(&(key.0, key.1, object))) - }); - - let (new_axioms, _) = - merge_axiom_derivation(&derivation, &blocked, &spans, &rules, &mut wanted); - report.derived += new_axioms; - report.capped = derivation.capped.len(); - report.blocked = blocked.len(); let mut evaluation_edges = edge_pool.clone(); - evaluation_edges.extend(relation_edges.iter().cloned()); + evaluation_edges.extend( + relation + .iter() + .filter(|r| !r.refused) + .map(|r| r.edge.clone()), + ); // 一轮之内先算完再入池:同一轮里规则读到的是上一轮结束时的池子, // 谁先谁后就不影响结果 let mut fresh: Vec<(usize, utopia_reason::rules::RuleHit)> = Vec::new(); @@ -2015,7 +2068,7 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult *capped_by_rule.entry(lr.rule.id).or_default() += capped; } - let before = wanted.len(); + let before = provisional.len(); for (ri, h) in fresh { let lr = &loaded.rules[ri]; if matches!( @@ -2031,9 +2084,15 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult h.from, h.to, ); - if wanted.contains_key(&key) { + // 上一轮已经推出过同一条(站得住的、被拦下的都算——被拦下的 + // 不再试第二次,不然它每轮进一次退一次,不动点就到不了) + if wanted.contains_key(&key) + || axiom_keys.contains(&key) + || relation.iter().any(|r| r.key == key) + { continue; } + // 同一条边已经断言在案、区间还交:asserted > derived,不另立一行 let overlaps_assertion = asserted_edges .get(&(h.subject, lr.conclude_predicate, object)) .is_some_and(|spans| { @@ -2048,28 +2107,34 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult let pm = premise_meta(&h.premises, h.from, h.to, &spans, &meta); spans.insert(prov, (h.from, h.to)); meta.insert(prov, pm); - relation_edges.push(utopia_reason::rules::RuleEdge { - id: prov, - predicate: lr.conclude_predicate, - subject: h.subject, - object, + relation.push(RelationCandidate { + edge: utopia_reason::rules::RuleEdge { + id: prov, + predicate: lr.conclude_predicate, + subject: h.subject, + object, + }, + key: key.clone(), + premises: h.premises.clone(), + rule_id: lr.rule.id, + refused: false, }); - relation_keys.push(key.clone()); - let candidate = Wanted { - subject: h.subject, - predicate: lr.conclude_predicate, - object_id: Some(object), - object_value: None, - from: h.from, - to: h.to, - premises: h.premises, - rule_id: None, - attribute_rule_id: Some(lr.rule.id), - }; - relation_candidates.insert(key.clone(), candidate.clone()); - let wanted_candidate = candidate.clone(); provisional.insert(key.clone(), prov); - wanted.insert(key, wanted_candidate); + wanted.insert( + key, + Wanted { + subject: h.subject, + predicate: lr.conclude_predicate, + object_id: Some(object), + object_value: None, + from: h.from, + to: h.to, + premises: h.premises, + rule_id: None, + attribute_rule_id: Some(lr.rule.id), + }, + ); + dirty = true; continue; } let (value, inner) = match &lr.rule.conclusion { @@ -2140,30 +2205,129 @@ pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult }, ); } - // 这一轮什么新东西都没推出来:不动点到了 - if provisional.len() == before { + // 这一轮什么新东西都没推出来、也没退出去什么:不动点到了 + if provisional.len() == before && !refused_now { break; } } - report.rule_rounds = rounds; + // 最后一轮加的关系边还没过检查:查到稳住为止,只是不再跑规则 + while dirty { + checked = check(&edges, &relation, &axioms, &spans, &accepted); + dirty = refuse_blocked( + &checked, + &mut relation, + &mut wanted, + &mut provisional, + &mut fact_pool, + ); + } + } + + Ok(Resolved { + edges, + spans, + meta, + axioms, + rules, + asserted_ids, + checked, + wanted, + provisional, + loaded, + capped_by_rule, + rounds, + rule_rounds_capped: rounds == utopia_reason::MAX_DEPTH, + }) +} + +pub async fn materialize(pool: &PgPool, kb_id: Uuid) -> AppResult { + let Resolved { + edges, + spans, + meta, + rules, + checked, + mut wanted, + provisional, + loaded, + capped_by_rule, + rounds, + rule_rounds_capped, + .. + } = resolve(pool, kb_id).await?; + let Checked { + candidates, + axiom_count, + blocked, + .. + } = checked; + + // 站得住的关系结论已经在 `provisional` 里;候选里在它们之外的是被拦下的那些 + let live_relations = wanted + .values() + .filter(|w| w.attribute_rule_id.is_some() && w.object_id.is_some()) + .count(); + let mut report = DeriveReport { + rules: rules.len(), + edges: edges.len(), + // 引擎推出的派生数:最后一轮池子上的公理派生与关系候选(拦下的也推出来了), + // 加上规则的**不同结论**数——不动点里同一条结论每轮都会被重新算出来, + // 按次数累加就成了轮数的函数 + derived: candidates.facts.len() + provisional.len() - live_relations, + capped: candidates.capped.len(), + blocked: blocked.len(), + attribute_rules: loaded.rules.len(), + rule_rounds: rounds, // 跑满了轮数还在产出:链比 MAX_DEPTH 长,后面的没接上。**得报出来**—— // 「没推到」与「不满足」在结果里长得一模一样(组合封顶那条是同一个道理) - report.rule_rounds_capped = rounds == utopia_reason::MAX_DEPTH; - for lr in &loaded.rules { - // 展不完的组合数按规则写回:这个数字在表里常驻,而不只在「跑完那一刻」 - // 的提示里闪一下。取最后一轮的数——那一轮扫的是最全的池子 - let capped = capped_by_rule.get(&lr.rule.id).copied().unwrap_or(0); - sqlx::query("UPDATE attribute_rules SET capped_at_last_run = $2 WHERE id = $1") - .bind(lr.rule.id) - .bind(capped as i32) - .execute(pool) - .await?; - report.rule_capped += capped; + rule_rounds_capped, + rule_hits: provisional.len(), + ..Default::default() + }; + + // 公理派生并进同一个 `wanted`——**合流是必须的**:陈旧行的对账扫的是整张表, + // 两趟各做各的 diff 会把对方的行每轮都判成陈旧作废掉。取的是最后一轮池子上的 + // 那一份:站在退出的关系边上的派生已经不在里面 + for (i, d) in candidates.facts.iter().take(axiom_count).enumerate() { + if blocked.contains(&i) { + continue; } - // 命中数按**不同的结论**数,不按算出来多少次:不动点里同一条结论每轮都 - // 会被重新算出来,累加就成了轮数的函数 - report.rule_hits = provisional.len(); - report.derived += provisional.len(); + let Some((from, to)) = utopia_reason::derive::validity(&d.premises, &spans) else { + continue; + }; + // **按 `via` 查,不是 `predicate`。** 规则行是给「声明了公理的那个 + // 谓词」编的;跨谓词的两条规则里,派生出来的谓词是另一个 + let Some(&rule_id) = rules.get(&(d.via, d.rule.as_str())) else { + // 查不到规则是**编译与推导不一致**,不是正常情况。数出来, + // 别再让它静默消失一次 + report.unruled += 1; + continue; + }; + // 规则推出的同一条关系先到:那一行带着规则的证明落,公理那份不另立 + wanted + .entry((d.subject, d.predicate, Some(d.object), None, from, to)) + .or_insert(Wanted { + subject: d.subject, + predicate: d.predicate, + object_id: Some(d.object), + object_value: None, + from, + to, + premises: d.premises.clone(), + rule_id: Some(rule_id), + attribute_rule_id: None, + }); + } + for lr in &loaded.rules { + // 展不完的组合数按规则写回:这个数字在表里常驻,而不只在「跑完那一刻」 + // 的提示里闪一下。取最后一轮的数——那一轮扫的是最全的池子 + let capped = capped_by_rule.get(&lr.rule.id).copied().unwrap_or(0); + sqlx::query("UPDATE attribute_rules SET capped_at_last_run = $2 WHERE id = $1") + .bind(lr.rule.id) + .bind(capped as i32) + .execute(pool) + .await?; + report.rule_capped += capped; } let mut tx = pool.begin().await?; diff --git a/crates/utopia-store/tests/a_rule_concludes_a_relation.rs b/crates/utopia-store/tests/a_rule_concludes_a_relation.rs index cc82fd064..7aa3f060f 100644 --- a/crates/utopia-store/tests/a_rule_concludes_a_relation.rs +++ b/crates/utopia-store/tests/a_rule_concludes_a_relation.rs @@ -164,6 +164,34 @@ async fn edge( Ok(id) } +/// 同一条谓词、同一个主语,宾语另指:给 functional 那条公理一个可以撞的对象 +async fn edge_to( + pool: &PgPool, + f: &Fixture, + predicate: Uuid, + object: Uuid, + from: &str, + to: &str, +) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, + valid_from, valid_from_precision, + valid_to, valid_to_precision, confidence) + VALUES ($1, $2, $3, $4, $5, $6, 'day', $7, 'day', 0.9)", + ) + .bind(id) + .bind(f.kb) + .bind(f.x) + .bind(predicate) + .bind(object) + .bind(from.parse::>()?) + .bind(to.parse::>()?) + .execute(pool) + .await?; + Ok(id) +} + fn conditions(f: &Fixture) -> [ConditionInput; 2] { [ ConditionInput { @@ -283,3 +311,121 @@ async fn a_joined_rule_reads_the_other_side_of_a_declared_edge() -> anyhow::Resu .await?; run } + +/// 一条规则推出的关系边撞上断言时不落地,而审核队列要看得见它(0017,0047 决定 3): +/// `run()` 与 `materialize()` 从同一次求解取候选,被拦下的关系候选留在候选里, +/// 队列那一行说明它是哪条业务规则推出来的、撞在哪条断言上 +#[tokio::test] +async fn a_relation_the_graph_refuses_still_reaches_the_review_queue() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let run = async { + // upstream_of 是 functional:X 已经断言了另一个上游 Z,规则推出的 X → Y + // 与它区间相交,asserted > derived,这条派生不落地 + sqlx::query("UPDATE relation_types SET functional = true WHERE id = $1") + .bind(f.upstream_of) + .execute(&pool) + .await?; + let z = Uuid::now_v7(); + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) + VALUES ($1, $2, (SELECT type_id FROM entities WHERE id = $3), 'F-3')", + ) + .bind(z) + .bind(f.kb) + .bind(f.y) + .execute(&pool) + .await?; + let other_upstream = edge_to( + &pool, + &f, + f.upstream_of, + z, + "2024-01-01T00:00:00Z", + "2024-12-31T00:00:00Z", + ) + .await?; + attr(&pool, &f, f.x, f.pressure, 120.0, "2024-01-01T00:00:00Z").await?; + attr(&pool, &f, f.y, f.depth, 300.0, "2024-01-15T00:00:00Z").await?; + let join = edge( + &pool, + &f, + f.supplies, + "2024-01-01T00:00:00Z", + "2024-02-01T00:00:00Z", + ) + .await?; + let rule = utopia_store::business_rules::create( + &pool, + f.kb, + "upstream high pressure", + "", + f.well, + "relation", + None, + Some(f.upstream_of), + None, + None, + Some(f.supplies), + &conditions(&f), + ) + .await?; + + let report = utopia_store::reasoning::materialize(&pool, f.kb).await?; + assert_eq!( + report.blocked, 1, + "the relation lost to the asserted upstream" + ); + assert_eq!(report.inserted, 0, "{report:?}"); + assert_eq!( + report.rule_hits, 0, + "a refused conclusion is not a conclusion that stands" + ); + let (derived_rows,): (i64,) = sqlx::query_as( + "SELECT count(*) FROM derived_facts WHERE kb_id = $1 AND invalidated_at IS NULL", + ) + .bind(f.kb) + .fetch_one(&pool) + .await?; + assert_eq!(derived_rows, 0, "nothing lands"); + + let check = utopia_store::reasoning::run(&pool, f.kb).await?; + assert_eq!(check.contradictions, 1, "{check:?}"); + let rows: Vec<(Uuid, Uuid, serde_json::Value)> = sqlx::query_as( + "SELECT left_fact, right_fact, detail FROM axiom_violations + WHERE kb_id = $1 AND kind = 'derived_contradiction' AND status = 'open'", + ) + .bind(f.kb) + .fetch_all(&pool) + .await?; + assert_eq!(rows.len(), 1, "{rows:?}"); + let (left, right, detail) = &rows[0]; + assert_eq!(*left, other_upstream, "against the asserted upstream"); + assert_eq!( + *right, join, + "keyed by the last asserted premise: the join edge" + ); + assert_eq!(detail["rule"], "business_rule"); + assert_eq!(detail["axiom"], "functional"); + assert_eq!(detail["attribute_rule_id"], serde_json::json!(rule)); + assert_eq!(detail["object_id"], serde_json::json!(f.y)); + + // 同一次求解,第二遍不多不少:队列跟图对得上 + let again = utopia_store::reasoning::run(&pool, f.kb).await?; + assert_eq!(again.inserted, 0, "{again:?}"); + assert_eq!(again.cleared, 0, "{again:?}"); + Ok::<_, anyhow::Error>(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(&pool) + .await?; + run +} diff --git a/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs b/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs index b74ad90f7..e3e61b90b 100644 --- a/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs +++ b/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs @@ -161,6 +161,7 @@ async fn step_rule( predicate_id: *predicate, op: "in".into(), operand: Some(json!([place])), + side: "x".into(), }) .collect(); Ok(business_rules::create( @@ -174,6 +175,7 @@ async fn step_rule( None, None, None, + None, &conditions, ) .await?) diff --git a/docs/decisions/0047-a-rule-may-conclude-a-relation.md b/docs/decisions/0047-a-rule-may-conclude-a-relation.md index 05c684624..db8d3631f 100644 --- a/docs/decisions/0047-a-rule-may-conclude-a-relation.md +++ b/docs/decisions/0047-a-rule-may-conclude-a-relation.md @@ -1,6 +1,6 @@ # 0047 · A rule may conclude a relation -- **Status**: Implemented 2026-09-22 · pending review · revises the edge exclusion stated in [0021](0021-a-rule-reads-attributes-and-concludes-a-type.md), and asks the question [0030](0030-a-rule-may-read-what-a-rule-concluded.md) parked as "nobody has asked it" +- **Status**: Implemented in #861 (migration 0071), 2026-09-25 · caps unchanged, decision 4's measurement is in the PR · revises the edge exclusion stated in [0021](0021-a-rule-reads-attributes-and-concludes-a-type.md), and asks the question [0030](0030-a-rule-may-read-what-a-rule-concluded.md) parked as "nobody has asked it" - **Written**: 2026-09-20 (conventions in the [README](README.md)) - **Related**: [0021](0021-a-rule-reads-attributes-and-concludes-a-type.md) built the rule and excluded an edge conclusion; [0030](0030-a-rule-may-read-what-a-rule-concluded.md) replaced that exclusion's acyclicity argument with a finiteness one on the value channel, and this record carries it to the edge channel; **[0032](0032-a-rule-computes-what-it-concludes.md) already decided that a rule may reach a value across one relation** and has not built it — this record depends on that loader rather than re-deciding it; [0002](0002-reasoning-engine.md) built the axiom fixed point and left R3 open; [0024](0024-the-world-axis-reaches-the-second.md) governs the precision of a derived bound; [0013](0013-a-source-should-hand-over-its-history.md) forbids reading a previous run's output, which stays forbidden. From #818. @@ -43,7 +43,7 @@ What changes is the **size** of the space rather than its finiteness: edges are ## What it costs -**`derive()` runs per round.** 0030 puts the analogous move on the value side at two to three times a single pass, because real chains are one or two links and the fixed point converges when a round adds no new key — but that figure is an estimate in a code comment rather than a measurement, so it is a reason to expect the cost to be tolerable and no evidence that it is. Here the pass being repeated is the expensive one, so this number is measured before the cut lands. +**`derive()` runs per round.** 0030 puts the analogous move on the value side at two to three times a single pass, because real chains are one or two links and the fixed point converges when a round adds no new key — but that figure is an estimate in a code comment rather than a measurement, so it is a reason to expect the cost to be tolerable and no evidence that it is. Here the pass being repeated is the expensive one, so this number is measured before the cut lands. Measured in #861 (release build, synthetic base, one Y per X): one `derive()` pass over 100,000 asserted edges takes 29 ms, and 52 ms with 100,000 concluded edges added to the pool, so a round costs the rule evaluation, not the axiom pass. Joined evaluation is linear in pairs times combinations once the join edges are bucketed by subject: 100,000 pairs at one reading a side in 105 ms, at 64 combinations a pair (the `MAX_COMBOS` ceiling) in 1.5 s. Before bucketing it was quadratic in pairs, 5.9 s at 100,000, which is the kind of number decision 4 exists to catch. The three caps stay as they are; the SEC and contracts corpora were not to hand for this cut, so the measurement is synthetic and should be repeated on them when they are. **Contradiction checking moves inside the round.** Today `contradictions()` runs once on the single derivation, and `blocked` is computed before the rule rounds start. A concluded edge can contradict an assertion or another derivation, so the check has to see the edges a round added. The blocked set is also an input to the next round: an edge that lost to an assertion must not be joined on, or a rule fires on something the graph refused to show. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0f70b4281..e827ab3e8 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -72,7 +72,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0044 | [The ontology is a view over what documents say](0044-the-ontology-is-a-view-over-what-documents-say.md) | Accepted · cut 1 built (#731, #735, #736, #741, #743–#745): extraction writes open statements, memory documents take the same path, the typed path is deleted, kind words bind to classes · cut 2 (alignment producing typed facts, identity profiles, the errata agent) not built · three layers: extraction writes an open graph in the documents' words (0 of 333 statements unstated in the prototype, against 4–9% of facts bound at write time), a small ontology proposed by an agent and approved by people, and a typed graph computed from the open graph on cached signatures · time mentions resolved against a document time context by code · identity across documents on deterministic evidence before the adjudicator · an errata agent reviews the typed graph | | 0045 | [A time mention is resolved against its document](0045-a-time-mention-is-resolved-against-its-document.md) | Accepted · cuts 1 and 2 built (#740): a document is dated from its own text, each mention is interpreted by the model and computed by code, upload time is used nowhere · cuts 3 and 4 (grades replace the confidence gate, re-resolution and the anchor queue) not built · a time expression is a mention with its words and place; the model returns shape, anchor, offset and granularity and code computes the interval; a document carries its own date, calendars and anchors across chunks, never its upload time; unresolved mentions wait for an anchor; timelines close on resolution grade instead of confidence | | 0046 | [The app surface is MCP](0046-the-app-surface-is-mcp.md) | Decided, with the refused design kept. Asked for an app center: applications built on this knowledge, mounted, run in a sandbox, handed to a team. The answer is that the surface already exists — a personal token carries identity and scope, ten read tools serve chat and MCP from one place, `as_of` reaches every graph read, and a read returns `structuredContent` with stable ledger identities — so a coding agent builds on this base today in its own platform, its own language and its own sandbox. Refused here because the layer an app would read is being replaced under it (typed facts now come only from alignment), because 0016 closes open seams before cutting new ones, and because a catalog, an execution boundary and quotas are three other products. The shape is kept with the four gates it would have to hold (runs as the caller, egress only through a declared action, a declared clock, the existing queue) and the dead ends: a container runtime (withdrawn the day it was written — WeKnora's skills are human-written and assume a shell, and they pay for it), a Wasm component runtime (better on every axis including the determinism re-parse needs, still not built because the reason is priority), a service identity per app, an app as a saved conversation. Reopened by a named customer who needs a button inside the product, by the type layer settling, or after 0034 | -| 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | Implemented 2026-09-22 · pending review · A rule reads one entity and concludes about that same entity, so a threshold over a chain — a holding above 50% in a company that itself holds above 50% in another — cannot be written at all, and the query-time path walk that answers it produces no interval, no premises and nothing a queue can see. The conclusion becomes a **relation** between the subject and one entity reached across one declared relation, valid on the intersection of every premise interval including the join edge's. The concluded edge rejoins the pool `derive()` reads and the axiom pass runs once per round, coupling the two reasoners for the first time: 0021's cycle objection is answered with the **finiteness** argument [0030](0030-a-rule-may-read-what-a-rule-concluded.md) already put in place of acyclicity, rather than with a fixed ordering that would let a legitimate rule silently never fire. Reading a value across a hop is [0032](0032-a-rule-computes-what-it-concludes.md)'s decision, reused rather than re-decided. Negation, aggregation, a second hop and user-defined recursion stay out; the existing caps stay in place because this cut changes none of them | +| 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | Implemented in #861 (migration 0071) 2026-09-25 · caps unchanged, measured in the PR · A rule reads one entity and concludes about that same entity, so a threshold over a chain — a holding above 50% in a company that itself holds above 50% in another — cannot be written at all, and the query-time path walk that answers it produces no interval, no premises and nothing a queue can see. The conclusion becomes a **relation** between the subject and one entity reached across one declared relation, valid on the intersection of every premise interval including the join edge's. The concluded edge rejoins the pool `derive()` reads and the axiom pass runs once per round, coupling the two reasoners for the first time: 0021's cycle objection is answered with the **finiteness** argument [0030](0030-a-rule-may-read-what-a-rule-concluded.md) already put in place of acyclicity, rather than with a fixed ordering that would let a legitimate rule silently never fire. Reading a value across a hop is [0032](0032-a-rule-computes-what-it-concludes.md)'s decision, reused rather than re-decided. Negation, aggregation, a second hop and user-defined recursion stay out; the existing caps stay in place because this cut changes none of them | | 0048 | [Provenance references stay inside the knowledge base](0048-provenance-references-stay-inside-the-knowledge-base.md) | Implemented in PR #832 (migration 0070) · a column foreign key proves the target exists, not that it is the same KB's — every reference an export can resolve gets a schema-level same-KB invariant: composite `(kb_id, ref)` foreign keys on the 26 edges whose row carries its own `kb_id` (same-table self-references deferred to commit), row triggers on the 13 whose kb authority is a parent row, `kb_id` immutability on every owned table, and a precondition scan that fails the migration closed on an already-cross-KB ledger · a catalog-derived guard keeps the 39 edges covered in the schema and, with #874, in export preflight · measured populate cost within noise; mechanism choice settled in #832 (discussion in issue #842) | | 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | Proposed · declaration policy pending; opt-in web draft only | | 0050 | [An action attempt keeps its identity and uncertain outcome](0050-an-action-attempt-keeps-its-identity-and-uncertain-outcome.md) | Proposed · durable execution identity and uncertain outcomes; no sender |