From acb6b84414e9f0a5d8b52ab236033344a255649d Mon Sep 17 00:00:00 2001 From: Virtue039 Date: Mon, 19 Oct 2026 15:37:47 +0800 Subject: [PATCH 1/3] A regression case can expect no binding while a person's binding remains authoritative Signed-off-by: Virtue039 --- crates/utopia-cli/src/main.rs | 2 +- .../src/api/ontology_regression_routes.rs | 27 +- .../api/ontology_regression_routes_tests.rs | 161 ++++++++- crates/utopia-server/src/ontology_agent.rs | 44 ++- .../utopia-server/src/ontology_agent_tests.rs | 70 +++- .../src/phrase_alignment_tests.rs | 41 +++ .../src/export_provenance_integrity.sql | 2 +- .../utopia-store/src/ontology_regressions.rs | 125 +++++-- .../tests/store/ontology_regressions.rs | 309 +++++++++++++++--- docs/design/ontology.md | 7 + ..._regression_case_can_expect_no_binding.sql | 12 + web/src/api.ts | 20 +- web/src/i18n/en.ts | 9 +- web/src/i18n/zh.ts | 9 +- web/src/pages/AddOntologyRegressionDialog.tsx | 20 +- .../pages/OntologyRegressionsPanel.test.ts | 23 +- web/src/pages/OntologyRegressionsPanel.tsx | 9 +- 17 files changed, 766 insertions(+), 124 deletions(-) create mode 100644 migrations/0115_a_regression_case_can_expect_no_binding.sql diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index c88fc1b7c..2d09f0936 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 = 102; +const CURRENT_SCHEMA_VERSION: u32 = 103; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-server/src/api/ontology_regression_routes.rs b/crates/utopia-server/src/api/ontology_regression_routes.rs index 976b81b39..c4d01369e 100644 --- a/crates/utopia-server/src/api/ontology_regression_routes.rs +++ b/crates/utopia-server/src/api/ontology_regression_routes.rs @@ -11,11 +11,21 @@ use utopia_core::models::Role; use utopia_store::{access::require_kb, ontology_regressions as cases}; use uuid::Uuid; +#[derive(Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExpectedBinding { + #[default] + Bound, + None, +} + #[derive(Deserialize)] pub struct Create { pub statement_id: Uuid, - pub relation_type_id: Uuid, - pub direction: String, + #[serde(default)] + pub expected_binding: ExpectedBinding, + pub relation_type_id: Option, + pub direction: Option, } pub async fn list( @@ -34,13 +44,24 @@ pub async fn create( Json(req): Json, ) -> ApiResult<(StatusCode, Json)> { require_kb(&state.pool, &user, kb, Role::Editor).await?; + let valid = match req.expected_binding { + ExpectedBinding::Bound => req.relation_type_id.is_some() && req.direction.is_some(), + ExpectedBinding::None => req.relation_type_id.is_none() && req.direction.is_none(), + }; + if !valid { + return Err(utopia_core::AppError::invalid( + "bad_case_expectation", + "A bound expectation needs a property and direction; a none expectation has neither.", + ) + .into()); + } let id = cases::add( &state.pool, kb, cases::NewCase { statement_id: req.statement_id, expected_property_id: req.relation_type_id, - expected_direction: &req.direction, + expected_direction: req.direction.as_deref(), created_by: user.id, origin: "person", }, diff --git a/crates/utopia-server/src/api/ontology_regression_routes_tests.rs b/crates/utopia-server/src/api/ontology_regression_routes_tests.rs index 6ba5da68b..7f347c25d 100644 --- a/crates/utopia-server/src/api/ontology_regression_routes_tests.rs +++ b/crates/utopia-server/src/api/ontology_regression_routes_tests.rs @@ -16,23 +16,30 @@ async fn post( token: &str, statement: Uuid, property: Uuid, +) -> anyhow::Result { + post_body( + app, + kb, + token, + json!({ "statement_id": statement, + "relation_type_id": property, "direction": "forward" }), + ) + .await +} + +async fn post_body( + app: &Router, + kb: Uuid, + token: &str, + body: serde_json::Value, ) -> anyhow::Result { Ok(app .clone() .oneshot( - Request::builder() - .method("POST") - .uri(format!("/api/v1/kbs/{kb}/ontology/regressions")) + Request::post(format!("/api/v1/kbs/{kb}/ontology/regressions")) .header("content-type", "application/json") .header("authorization", format!("Bearer {token}")) - .body(Body::from( - json!({ - "statement_id": statement, - "relation_type_id": property, - "direction": "forward" - }) - .to_string(), - ))?, + .body(Body::from(body.to_string()))?, ) .await?) } @@ -134,12 +141,140 @@ async fn editor_adds_regression_case_while_viewer_and_cross_kb_statement_are_rej .await?; anyhow::ensure!(saved); + let negative = graph::insert_open_statement( + &pool, + kb, + subject, + "mentioned alongside", + graph::FactObject::Entity(object), + None, + 1.0, + ) + .await? + .0; + for fields in [ + json!({}), + json!({"expected_binding":"bound"}), + json!({"expected_binding":"unknown"}), + json!({"expected_binding":null}), + json!({"relation_type_id":property}), + json!({"direction":"forward"}), + json!({"expected_binding":"none","relation_type_id":property}), + json!({"expected_binding":"none","direction":"reverse"}), + json!({"relation_type_id":property,"direction":"sideways"}), + ] { + let mut body = fields; + body["statement_id"] = json!(negative); + // The JSON extractor rejects an explicit null enum before the handler. + let expected = if body.get("expected_binding") == Some(&serde_json::Value::Null) { + StatusCode::BAD_REQUEST + } else { + StatusCode::UNPROCESSABLE_ENTITY + }; + assert_eq!( + post_body(&app, kb, &editor_token, body.clone()) + .await? + .status(), + expected, + "{body}" + ); + } + let mut negative_id = serde_json::Value::Null; + for targets in [json!({}), json!({"relation_type_id":null,"direction":null})] { + let mut request = targets; + request["statement_id"] = json!(negative); + request["expected_binding"] = json!("none"); + let response = post_body(&app, kb, &editor_token, request).await?; + assert_eq!(response.status(), StatusCode::CREATED); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await?)?; + if negative_id.is_null() { + negative_id = body["id"].clone(); + } + assert_eq!(body["id"], negative_id); + } + for request in [ + json!({"statement_id":statement,"expected_binding":"none"}), + json!({"statement_id":negative,"relation_type_id":property,"direction":"forward"}), + ] { + let response = post_body(&app, kb, &editor_token, request).await?; + assert_eq!(response.status(), StatusCode::CONFLICT); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await?)?; + assert_eq!(body["code"], "conflicting_case_expectation"); + } + let sig = utopia_store::phrase_bindings::signatures(&pool, kb) + .await? + .into_iter() + .find(|s| s.phrase == "mentioned alongside") + .unwrap(); + utopia_store::phrase_bindings::decide( + &pool, + kb, + &sig, + utopia_store::phrase_bindings::Decision { + relation_type_id: Some(property), + direction: Some("forward"), + status: "bound", + decided_by: "person", + votes: &serde_json::Value::Null, + basis: None, + marks: None, + marks_asked: false, + }, + ) + .await?; + // Negative expectations remain visible even though they have no property to join. + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/kbs/{kb}/ontology/regressions")) + .header("authorization", format!("Bearer {viewer_token}")) + .body(Body::empty())?, + ) + .await?; + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await?)?; + let case = body["cases"] + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == negative_id) + .unwrap(); + assert_eq!(case["expected_binding"], "none"); + for key in [ + "expected_property_id", + "expected_direction", + "expected_property_label", + "expected_property_key", + ] { + assert!(case[key].is_null(), "{key}: {case}"); + } + assert_eq!(case["last_result"]["conflict"], "human_binding"); + assert_eq!(case["last_result"]["passed"], false); + assert_eq!(case["last_result"]["actual_property_id"], json!(property)); + assert_eq!(case["actual_property_key"], "worksFor"); + assert_eq!(case["subject_label"], "Alice"); + assert_eq!(case["statement_id"], json!(negative)); + for (token, statement, expected) in [ (&viewer_token, statement, StatusCode::FORBIDDEN), (&editor_token, foreign_statement, StatusCode::NOT_FOUND), ] { let response = post(&app, kb, token, statement, property).await?; anyhow::ensure!(response.status() == expected); + assert_eq!( + post_body( + &app, + kb, + token, + json!({"statement_id":statement,"expected_binding":"none"}) + ) + .await? + .status(), + expected + ); } anyhow::Ok(()) } @@ -209,8 +344,8 @@ async fn viewer_reads_only_live_cases_in_the_authorized_kb() -> anyhow::Result<( ).await?.0; utopia_store::ontology_regressions::add(&pool, case_kb, utopia_store::ontology_regressions::NewCase { - statement_id: id, expected_property_id: expected, - expected_direction: "forward", created_by: viewer, origin: "person", + statement_id: id, expected_property_id: Some(expected), + expected_direction: Some("forward"), created_by: viewer, origin: "person", }, ).await?; if case_kb == kb { diff --git a/crates/utopia-server/src/ontology_agent.rs b/crates/utopia-server/src/ontology_agent.rs index 6b61a96b9..972cf85f8 100644 --- a/crates/utopia-server/src/ontology_agent.rs +++ b/crates/utopia-server/src/ontology_agent.rs @@ -1062,6 +1062,12 @@ async fn adopt_on( return Err(AppError::NotFound); }; let p = &locked.proposal; + // Binding writes also update existing cases. Take their confirmation locks first, + // then let the enclosing transaction roll back any opposite-expectation conflict. + for (statement, _) in regression_examples(p, &[])? { + utopia_store::ontology_regressions::lock_statement(tx, kb_id, statement).await?; + } + // 人改过几格:决定 5 的第二个数要它 let edited = edits.label.is_some() || edits.description.is_some() @@ -1203,19 +1209,10 @@ async fn embed_then_align( Ok(()) } -/// 回归用例和采纳的状态(决定 4),在采纳的事务里。人改过没有(决定 5 的第二个数)和状态同一条 -/// UPDATE 写下:从前是采纳提交之后的另一条语句。复用留着没写的形状(`skipped`)不写用例:人定得 -/// 和提案不一样;人后来定得一样的照写 -async fn finish_adoption( - tx: &mut Transaction<'_, Postgres>, - kb_id: Uuid, - locked: &Locked, - property_id: Uuid, - actor: Uuid, - edited: bool, +fn regression_examples( + proposal: &StoredProposal, skipped: &[Value], -) -> Result<(), AppError> { - let proposal = &locked.proposal; +) -> Result, AppError> { let mut examples = Vec::new(); let property = matches!( proposal.section.as_str(), @@ -1244,6 +1241,25 @@ async fn finish_adoption( examples.extend(ids.into_iter().map(|statement| (statement, direction))); } } + // Multiple proposals can share examples; acquire case locks in the same order. + examples.sort_unstable(); + examples.dedup(); + Ok(examples) +} + +/// 回归用例和采纳的状态(决定 4),在采纳的事务里。人改过没有(决定 5 的第二个数)和状态同一条 +/// UPDATE 写下:从前是采纳提交之后的另一条语句。复用留着没写的形状(`skipped`)不写用例:人定得 +/// 和提案不一样;人后来定得一样的照写 +async fn finish_adoption( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, + locked: &Locked, + property_id: Uuid, + actor: Uuid, + edited: bool, + skipped: &[Value], +) -> Result<(), AppError> { + let examples = regression_examples(&locked.proposal, skipped)?; for (statement_id, direction) in examples { // Removed examples cannot be checked; never substitute another statement. let live: Option = sqlx::query_scalar("SELECT id FROM facts WHERE kb_id=$1 AND id=$2 AND layer='open' AND invalidated_at IS NULL FOR SHARE") @@ -1256,8 +1272,8 @@ async fn finish_adoption( kb_id, utopia_store::ontology_regressions::NewCase { statement_id, - expected_property_id: property_id, - expected_direction: direction, + expected_property_id: Some(property_id), + expected_direction: Some(direction), created_by: actor, origin: "adoption", }, diff --git a/crates/utopia-server/src/ontology_agent_tests.rs b/crates/utopia-server/src/ontology_agent_tests.rs index e6073af9d..8d444c3e4 100644 --- a/crates/utopia-server/src/ontology_agent_tests.rs +++ b/crates/utopia-server/src/ontology_agent_tests.rs @@ -461,8 +461,8 @@ async fn an_unbound_shape_becomes_a_proposal_that_serves_a_question_and_adoption let cases = utopia_store::ontology_regressions::list(&f.pool, f.kb).await?; assert_eq!(cases.len(), 1); assert_eq!(cases[0].statement_id, example_id); - assert_eq!(cases[0].expected_property_id, id); - assert_eq!(cases[0].expected_direction, "forward"); + assert_eq!(cases[0].expected_property_id, Some(id)); + assert_eq!(cases[0].expected_direction.as_deref(), Some("forward")); assert_eq!(cases[0].created_by, Some(f.user)); assert_eq!(cases[0].origin, "adoption"); assert!( @@ -1214,3 +1214,69 @@ async fn a_class_deleted_during_adoption_answers_unknown_class() -> anyhow::Resu assert_eq!(f.left_by("city").await?, (false, "open".to_string(), 0, 0)); f.cleanup().await } + +#[tokio::test] +async fn a_negative_example_rolls_back_property_creation_and_reuse() -> anyhow::Result<()> { + for (section, key) in [("relation_types", "founded_in"), ("map_to", "located_in")] { + let Some(f) = Fx::new(Vec::new()).await? else { + return Ok(()); + }; + let result = async { + f.decide_none().await?; + let shape = f.shape("founded in").await?; + let statement_id = serde_json::from_value(shape["example_statement_ids"][0].clone())?; + let case = utopia_store::ontology_regressions::add( + &f.pool, + f.kb, + utopia_store::ontology_regressions::NewCase { + statement_id, + expected_property_id: None, + expected_direction: None, + created_by: f.user, + origin: "person", + }, + ) + .await?; + let before = utopia_store::ontology_regressions::get(&f.pool, f.kb, case) + .await? + .unwrap(); + f.seed( + section, + key, + if section == "map_to" { + json!({"kind":"relation"}) + } else { + founded_in() + }, + json!({"phrases":[shape]}), + ) + .await?; + let (status, body) = f + .post( + "/ontology/proposals/adopt", + json!({"section":section,"key":key}), + ) + .await?; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["code"], "conflicting_case_expectation"); + assert_eq!( + f.left_by(key).await?, + (section == "map_to", "open".into(), 0, 1) + ); + assert!(phrase_bindings::bindings(&f.pool, f.kb) + .await? + .iter() + .all(|b| b.status == "none" && b.decided_by == "agent")); + let after = utopia_store::ontology_regressions::get(&f.pool, f.kb, case) + .await? + .unwrap(); + assert_eq!(before.last_result, after.last_result); + assert_eq!(before.last_checked_at, after.last_checked_at); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + result?; + } + Ok(()) +} diff --git a/crates/utopia-server/src/phrase_alignment_tests.rs b/crates/utopia-server/src/phrase_alignment_tests.rs index bc485195b..6f4e39869 100644 --- a/crates/utopia-server/src/phrase_alignment_tests.rs +++ b/crates/utopia-server/src/phrase_alignment_tests.rs @@ -1469,3 +1469,44 @@ async fn asking_what_a_date_marks_never_reopens_the_binding() -> anyhow::Result< f.cleanup().await?; run } + +#[tokio::test] +async fn a_negative_case_follows_ordinary_rejudgement_and_keeps_human_binding() -> anyhow::Result<()> +{ + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + use utopia_store::ontology_regressions as cases; + let actor = f.editor().await?.id; + let statement_id = sqlx::query_scalar("SELECT id FROM facts WHERE kb_id=$1 AND layer='open'") + .bind(f.kb).fetch_one(&f.pool).await?; + let id = cases::add(&f.pool,f.kb,cases::NewCase { statement_id, expected_property_id: None, + expected_direction: None, created_by: actor, origin: "person" }).await?; + f.script(none()); f.run().await?; + assert_eq!(cases::get(&f.pool,f.kb,id).await?.unwrap().last_result.unwrap()["passed"],true); + assert_eq!(f.requests().len(),1); + f.clear_jobs().await?; + sqlx::query("UPDATE relation_types SET description='New definition',updated_at=clock_timestamp() WHERE id=$1") + .bind(f.based_in).execute(&f.pool).await?; + f.script(bound()); f.run().await?; + let result = cases::get(&f.pool,f.kb,id).await?.unwrap().last_result.unwrap(); + assert_eq!(result["status"],"bound"); assert_eq!(result["passed"],false); + assert_eq!(f.requests().len(),3,"cases do not add model calls"); + let sig = phrase_bindings::signatures(&f.pool,f.kb).await?.remove(0); + phrase_bindings::decide(&f.pool,f.kb,&sig,phrase_bindings::Decision { + relation_type_id:Some(f.based_in),direction:Some("forward"),status:"bound",decided_by:"person", + votes:&Value::Null,basis:None,marks:None,marks_asked:false, + }).await?; + let before = f.binding().await?; + f.run().await?; + let result = cases::get(&f.pool,f.kb,id).await?.unwrap().last_result.unwrap(); + assert_eq!(result["passed"],false); assert_eq!(result["conflict"],"human_binding"); + assert_eq!(f.requests().len(),3); + let after = f.binding().await?; + assert_eq!(before.decided_at,after.decided_at); assert_eq!(after.relation_type_id,Some(f.based_in)); + anyhow::Ok(()) + }.await; + f.cleanup().await?; + run +} diff --git a/crates/utopia-store/src/export_provenance_integrity.sql b/crates/utopia-store/src/export_provenance_integrity.sql index e6c85f32c..c4c894832 100644 --- a/crates/utopia-store/src/export_provenance_integrity.sql +++ b/crates/utopia-store/src/export_provenance_integrity.sql @@ -339,7 +339,7 @@ SELECT edge, kind, COUNT(*) AS rows FROM ( SELECT 'regression.property', 'cross_kb', p.kb_id IS DISTINCT FROM c.kb_id FROM ontology_regression_cases c LEFT JOIN relation_types p ON p.id = c.expected_property_id - WHERE c.kb_id = $1 + WHERE c.kb_id = $1 AND c.expected_property_id IS NOT NULL UNION ALL -- @edge phrase_readings.entity_id -> entities.id -- 读数缓存:答出的实体喂给蕴含——别库的实体等于把别库语义读进本库 diff --git a/crates/utopia-store/src/ontology_regressions.rs b/crates/utopia-store/src/ontology_regressions.rs index d546e625f..d64084e7f 100644 --- a/crates/utopia-store/src/ontology_regressions.rs +++ b/crates/utopia-store/src/ontology_regressions.rs @@ -12,8 +12,9 @@ pub struct Case { pub id: Uuid, pub kb_id: Uuid, pub statement_id: Uuid, - pub expected_property_id: Uuid, - pub expected_direction: String, + pub expected_binding: String, + pub expected_property_id: Option, + pub expected_direction: Option, pub created_by: Option, pub created_at: DateTime, pub origin: String, @@ -23,8 +24,8 @@ pub struct Case { pub struct NewCase<'a> { pub statement_id: Uuid, - pub expected_property_id: Uuid, - pub expected_direction: &'a str, + pub expected_property_id: Option, + pub expected_direction: Option<&'a str>, pub created_by: Uuid, pub origin: &'a str, } @@ -40,8 +41,8 @@ pub struct CaseView { pub object_id: Option, pub object_label: Option, pub object_value: Option, - pub expected_property_label: String, - pub expected_property_key: String, + pub expected_property_label: Option, + pub expected_property_key: Option, pub actual_property_label: Option, pub actual_property_key: Option, pub created_by_label: Option, @@ -50,7 +51,8 @@ pub struct CaseView { /// The workbench reads saved results and live source labels without evaluating or writing. pub async fn views(pool: &PgPool, kb_id: Uuid) -> AppResult> { Ok(sqlx::query_as( - "SELECT c.*, f.subject_id, s.canonical_name AS subject_label, f.phrase, + "SELECT c.*, CASE WHEN c.expected_property_id IS NULL THEN 'none' ELSE 'bound' END AS expected_binding, + f.subject_id, s.canonical_name AS subject_label, f.phrase, f.object_id, o.canonical_name AS object_label, f.object_value, expected.label AS expected_property_label, expected.key AS expected_property_key, actual.label AS actual_property_label, actual.key AS actual_property_key, @@ -59,7 +61,7 @@ pub async fn views(pool: &PgPool, kb_id: Uuid) -> AppResult> { JOIN facts f ON f.kb_id=c.kb_id AND f.id=c.statement_id JOIN entities s ON s.kb_id=f.kb_id AND s.id=f.subject_id LEFT JOIN entities o ON o.kb_id=f.kb_id AND o.id=f.object_id - JOIN relation_types expected ON expected.kb_id=c.kb_id AND expected.id=c.expected_property_id + LEFT JOIN relation_types expected ON expected.kb_id=c.kb_id AND expected.id=c.expected_property_id LEFT JOIN relation_types actual ON actual.kb_id=c.kb_id AND actual.id=(c.last_result->>'actual_property_id')::uuid LEFT JOIN users u ON u.id=c.created_by @@ -73,7 +75,8 @@ pub async fn views(pool: &PgPool, kb_id: Uuid) -> AppResult> { pub async fn list(pool: &PgPool, kb_id: Uuid) -> AppResult> { Ok(sqlx::query_as( - "SELECT c.* FROM ontology_regression_cases c WHERE c.kb_id=$1 + "SELECT c.*, CASE WHEN c.expected_property_id IS NULL THEN 'none' ELSE 'bound' END AS expected_binding + FROM ontology_regression_cases c WHERE c.kb_id=$1 AND EXISTS(SELECT 1 FROM facts f WHERE f.id=c.statement_id AND f.kb_id=c.kb_id AND f.layer='open' AND f.invalidated_at IS NULL) ORDER BY c.created_at DESC,c.id", ) @@ -84,7 +87,8 @@ pub async fn list(pool: &PgPool, kb_id: Uuid) -> AppResult> { pub async fn get(pool: &PgPool, kb_id: Uuid, id: Uuid) -> AppResult> { Ok( - sqlx::query_as("SELECT * FROM ontology_regression_cases WHERE kb_id=$1 AND id=$2") + sqlx::query_as("SELECT *, CASE WHEN expected_property_id IS NULL THEN 'none' ELSE 'bound' END AS expected_binding + FROM ontology_regression_cases WHERE kb_id=$1 AND id=$2") .bind(kb_id) .bind(id) .fetch_optional(pool) @@ -99,13 +103,30 @@ pub async fn add(pool: &PgPool, kb_id: Uuid, case: NewCase<'_>) -> AppResult AppResult<()> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))") + .bind(format!("ontology_regression:{kb_id}:{statement_id}")) + .execute(conn) + .await?; + Ok(()) +} + /// Adoption calls this in its own transaction so marking a proposal adopted and keeping /// its examples cannot commit separately. Duplicate requests retain the first actor/origin. pub async fn add_on(conn: &mut PgConnection, kb_id: Uuid, case: NewCase<'_>) -> AppResult { - if !matches!(case.expected_direction, "forward" | "reverse") { + if !matches!( + (case.expected_property_id, case.expected_direction), + (None, None) | (Some(_), Some("forward" | "reverse")) + ) { return Err(AppError::invalid( - "bad_direction", - "Direction is forward or reverse.", + "bad_case_expectation", + "A bound expectation needs a property and direction; a none expectation has neither.", )); } if !matches!(case.origin, "adoption" | "person") { @@ -124,33 +145,64 @@ pub async fn add_on(conn: &mut PgConnection, kb_id: Uuid, case: NewCase<'_>) -> .fetch_optional(&mut *conn) .await?; let value = value.ok_or(AppError::NotFound)?; - let kind: Option = - sqlx::query_scalar("SELECT kind FROM relation_types WHERE kb_id=$1 AND id=$2 FOR SHARE") - .bind(kb_id) - .bind(case.expected_property_id) - .fetch_optional(&mut *conn) - .await?; - let kind = kind.ok_or(AppError::NotFound)?; - if value != (kind == "attribute") || (value && case.expected_direction != "forward") { - return Err(AppError::invalid( - "bad_property_shape", - "The property's kind and direction must fit the statement's object.", - )); + // Serialize opposite expectations too: separate unique indexes alone would allow + // a concurrent bound/none pair. Retries must retain the first actor and origin. + lock_statement(conn, kb_id, case.statement_id).await?; + let conflicting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM ontology_regression_cases WHERE kb_id=$1 AND statement_id=$2 + AND (expected_property_id IS NULL) <> $3)", + ) + .bind(kb_id) + .bind(case.statement_id) + .bind(case.expected_property_id.is_none()) + .fetch_one(&mut *conn) + .await?; + if conflicting { + return Err(AppError::CodedConflict { + code: "conflicting_case_expectation", + message: "This statement already has the opposite expectation. No case was added." + .into(), + }); + } + if let Some(property) = case.expected_property_id { + let kind: Option = sqlx::query_scalar( + "SELECT kind FROM relation_types WHERE kb_id=$1 AND id=$2 FOR SHARE", + ) + .bind(kb_id) + .bind(property) + .fetch_optional(&mut *conn) + .await?; + let kind = kind.ok_or(AppError::NotFound)?; + if value != (kind == "attribute") || (value && case.expected_direction != Some("forward")) { + return Err(AppError::invalid( + "bad_property_shape", + "The property's kind and direction must fit the statement's object.", + )); + } } // DO NOTHING followed by SELECT observes a concurrent inserter after its commit, // without turning an idempotent retry into an edit of the original confirmation. - let inserted:Option=sqlx::query_scalar("INSERT INTO ontology_regression_cases + let inserted: Option = sqlx::query_scalar( + "INSERT INTO ontology_regression_cases (id,kb_id,statement_id,expected_property_id,expected_direction,created_by,origin) VALUES($1,$2,$3,$4,$5,$6,$7) - ON CONFLICT(kb_id,statement_id,expected_property_id,expected_direction) DO NOTHING RETURNING id") - .bind(Uuid::now_v7()).bind(kb_id).bind(case.statement_id).bind(case.expected_property_id) - .bind(case.expected_direction).bind(case.created_by).bind(case.origin).fetch_optional(&mut *conn).await?; + ON CONFLICT DO NOTHING RETURNING id", + ) + .bind(Uuid::now_v7()) + .bind(kb_id) + .bind(case.statement_id) + .bind(case.expected_property_id) + .bind(case.expected_direction) + .bind(case.created_by) + .bind(case.origin) + .fetch_optional(&mut *conn) + .await?; let id = match inserted { Some(id) => id, None => { sqlx::query_scalar( "SELECT id FROM ontology_regression_cases WHERE kb_id=$1 AND statement_id=$2 - AND expected_property_id=$3 AND expected_direction=$4", + AND expected_property_id IS NOT DISTINCT FROM $3 AND expected_direction IS NOT DISTINCT FROM $4", ) .bind(kb_id) .bind(case.statement_id) @@ -173,15 +225,18 @@ pub async fn add_on(conn: &mut PgConnection, kb_id: Uuid, case: NewCase<'_>) -> } // The live statement determines the signature, exactly as production alignment does. -// A person's binding remains authoritative even when it differs from the example; the -// human_bound flag distinguishes that protection from an independent machine success. +// Comparing a case never changes a binding. Only positive cases retain the human-bound +// exception; a negative case records that disagreement explicitly as a failure. fn record_sql(fresh: bool) -> String { let checked_at = if fresh { "now()" } else { "b.decided_at" }; format!("UPDATE ontology_regression_cases c SET last_checked_at={checked_at}, - last_result=jsonb_build_object('passed',coalesce((b.decided_by='person' AND b.status='bound') OR - (b.status='bound' AND b.relation_type_id=c.expected_property_id AND b.direction=c.expected_direction),false), - 'human_bound',b.decided_by='person' AND b.status='bound','actual_property_id',b.relation_type_id, + last_result=jsonb_build_object('passed',coalesce(CASE WHEN c.expected_property_id IS NULL THEN b.status='none' + ELSE (b.decided_by='person' AND b.status='bound') OR + (b.status='bound' AND b.relation_type_id=c.expected_property_id AND b.direction=c.expected_direction) END,false), + 'human_bound',c.expected_property_id IS NOT NULL AND b.decided_by='person' AND b.status='bound', + 'conflict',CASE WHEN c.expected_property_id IS NULL AND b.decided_by='person' AND b.status='bound' THEN 'human_binding' END, + 'actual_property_id',b.relation_type_id, 'actual_direction',b.direction,'status',b.status,'decided_at',b.decided_at) FROM facts f JOIN entities s ON s.id=f.subject_id AND s.kb_id=f.kb_id LEFT JOIN entities o ON o.id=f.object_id AND o.kb_id=f.kb_id diff --git a/crates/utopia-store/tests/store/ontology_regressions.rs b/crates/utopia-store/tests/store/ontology_regressions.rs index c9bb42848..7627c310b 100644 --- a/crates/utopia-store/tests/store/ontology_regressions.rs +++ b/crates/utopia-store/tests/store/ontology_regressions.rs @@ -2,57 +2,136 @@ use sqlx::PgPool; use utopia_store::{graph, ontology_regressions as regressions, phrase_bindings}; use uuid::Uuid; -#[tokio::test] -async fn committed_decisions_report_matches_and_human_bindings() -> 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 (org, workspace, kb, actor, subject, object, property) = ( - Uuid::now_v7(), - Uuid::now_v7(), - Uuid::now_v7(), - Uuid::now_v7(), - Uuid::now_v7(), - Uuid::now_v7(), - Uuid::now_v7(), - ); - sqlx::query("INSERT INTO organizations(id,name) VALUES($1,'regression-cases')") - .bind(org) +struct Fx { + pool: PgPool, + org: Uuid, + kb: Uuid, + actor: Uuid, + subject: Uuid, + object: Uuid, + property: Uuid, +} +impl Fx { + async fn new() -> anyhow::Result> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(None); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let (org, workspace, kb, actor, subject, object, property) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + sqlx::query("INSERT INTO organizations(id,name) VALUES($1,'regression-cases')") + .bind(org) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO workspaces(id,org_id,name) VALUES($1,$2,'regression-cases')") + .bind(workspace) + .bind(org) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases(id,workspace_id,name) VALUES($1,$2,'regression-cases')", + ) + .bind(kb) + .bind(workspace) .execute(&pool) .await?; - let result = async { - sqlx::query("INSERT INTO workspaces(id,org_id,name) VALUES($1,$2,'regression-cases')") - .bind(workspace).bind(org).execute(&pool).await?; - sqlx::query("INSERT INTO knowledge_bases(id,workspace_id,name) VALUES($1,$2,'regression-cases')") - .bind(kb).bind(workspace).execute(&pool).await?; sqlx::query("INSERT INTO users(id,org_id,email,password_hash,display_name) VALUES($1,$2,$3,'fixture','Reviewer')") .bind(actor).bind(org).bind(format!("{actor}@example.test")).execute(&pool).await?; for (id, name) in [(subject, "A"), (object, "B")] { sqlx::query("INSERT INTO entities(id,kb_id,canonical_name) VALUES($1,$2,$3)") - .bind(id).bind(kb).bind(name).execute(&pool).await?; + .bind(id) + .bind(kb) + .bind(name) + .execute(&pool) + .await?; } - sqlx::query("INSERT INTO relation_types(id,kb_id,key,label) VALUES($1,$2,'acquired','acquired')") - .bind(property).bind(kb).execute(&pool).await?; + sqlx::query( + "INSERT INTO relation_types(id,kb_id,key,label) VALUES($1,$2,'acquired','acquired')", + ) + .bind(property) + .bind(kb) + .execute(&pool) + .await?; + Ok(Some(Self { + pool, + org, + kb, + actor, + subject, + object, + property, + })) + } +} + +#[tokio::test] +async fn committed_decisions_report_matches_and_human_bindings() -> anyhow::Result<()> { + let Some(Fx { + pool, + org, + kb, + actor, + subject, + object, + property, + }) = Fx::new().await? + else { + return Ok(()); + }; + let result = async { let statement_id = graph::insert_open_statement( - &pool, kb, subject, "acquired", graph::FactObject::Entity(object), None, 1.0, - ).await?.0; - let case_id = regressions::add(&pool, kb, regressions::NewCase { - statement_id, expected_property_id: property, expected_direction: "forward", - created_by: actor, origin: "person", - }).await?; + &pool, + kb, + subject, + "acquired", + graph::FactObject::Entity(object), + None, + 1.0, + ) + .await? + .0; + let case_id = regressions::add( + &pool, + kb, + regressions::NewCase { + statement_id, + expected_property_id: Some(property), + expected_direction: Some("forward"), + created_by: actor, + origin: "person", + }, + ) + .await?; let signature = phrase_bindings::signatures(&pool, kb).await?.remove(0); for (direction, decided_by, passed, human_bound) in [ ("forward", "agent", true, false), ("reverse", "agent", false, false), ("reverse", "person", true, true), ] { - phrase_bindings::decide(&pool, kb, &signature, phrase_bindings::Decision { - relation_type_id: Some(property), direction: Some(direction), status: "bound", - decided_by, votes: &serde_json::Value::Null, basis: None, - marks: None, marks_asked: false, - }).await?; + phrase_bindings::decide( + &pool, + kb, + &signature, + phrase_bindings::Decision { + relation_type_id: Some(property), + direction: Some(direction), + status: "bound", + decided_by, + votes: &serde_json::Value::Null, + basis: None, + marks: None, + marks_asked: false, + }, + ) + .await?; let case = regressions::get(&pool, kb, case_id).await?.unwrap(); let report = case.last_result.unwrap(); anyhow::ensure!(report["passed"] == passed); @@ -60,7 +139,161 @@ async fn committed_decisions_report_matches_and_human_bindings() -> anyhow::Resu anyhow::ensure!(report["actual_direction"] == direction); anyhow::ensure!(case.last_checked_at.is_some()); } - Ok(()) + anyhow::Ok(()) + } + .await; + sqlx::query("DELETE FROM organizations WHERE id=$1") + .bind(org) + .execute(&pool) + .await?; + result +} + +#[tokio::test] +async fn negative_cases_fail_on_human_bindings_and_pass_only_explicit_none() -> anyhow::Result<()> { + let Some(Fx { + pool, + org, + kb, + actor, + subject, + object, + property, + }) = Fx::new().await? + else { + return Ok(()); + }; + let result = async { + let statement_id = graph::insert_open_statement( + &pool, kb, subject, "acquired", graph::FactObject::Entity(object), None, 1.0, + ).await?.0; + let case_id = regressions::add(&pool, kb, regressions::NewCase { + statement_id, expected_property_id: Some(property), expected_direction: Some("forward"), + created_by: actor, origin: "person", + }).await?; + // Negative expectations compare the same committed ledger, including human choices. + let negative_statement = graph::insert_open_statement( + &pool, kb, subject, "reported alongside", graph::FactObject::Entity(object), None, 1.0, + ).await?.0; + let negative = || regressions::NewCase { statement_id: negative_statement, + expected_property_id: None, expected_direction: None, created_by: actor, origin: "person" }; + let negative_id = regressions::add(&pool, kb, negative()).await?; + let unchecked = regressions::get(&pool, kb, negative_id).await?.unwrap(); + assert_eq!(unchecked.expected_binding, "none"); + assert!(unchecked.last_result.is_none() && unchecked.last_checked_at.is_none()); + let sig = phrase_bindings::signatures(&pool, kb).await?.into_iter() + .find(|s| s.phrase == "reported alongside").unwrap(); + for (status, by, passed, conflict) in [ + ("undecided", "agent", false, false), ("bound", "agent", false, false), + ("none", "agent", true, false), ("bound", "person", false, true), + ("none", "person", true, false), + ] { + let bound = status == "bound"; + phrase_bindings::decide(&pool, kb, &sig, phrase_bindings::Decision { + relation_type_id: bound.then_some(property), direction: bound.then_some("forward"), + status, decided_by: by, votes: &serde_json::Value::Null, basis: None, + marks: None, marks_asked: false, + }).await?; + regressions::record_human(&pool, kb).await?; + let result = regressions::get(&pool, kb, negative_id).await?.unwrap().last_result.unwrap(); + assert_eq!(result["passed"], passed); + assert_eq!(result["human_bound"], false); + assert_eq!(result["conflict"], if conflict { serde_json::json!("human_binding") } else { serde_json::Value::Null }); + assert_eq!(result["actual_property_id"], serde_json::json!(bound.then_some(property))); + let stored: (String, Option, String) = sqlx::query_as( + "SELECT status,relation_type_id,decided_by FROM phrase_bindings WHERE kb_id=$1 AND phrase=$2" + ).bind(kb).bind(&sig.phrase).fetch_one(&pool).await?; + assert_eq!(stored, (status.into(), bound.then_some(property), by.into()), "comparison never rewrites a binding"); + } + // A new example of an already decided signature reports the original decision time. + let later_statement = graph::insert_open_statement( + &pool, kb, object, "reported alongside", graph::FactObject::Entity(subject), None, 1.0, + ).await?.0; + let later = regressions::add(&pool, kb, regressions::NewCase { statement_id: later_statement, ..negative() }).await?; + let original_time: bool = sqlx::query_scalar("SELECT c.last_checked_at=b.decided_at AND (c.last_result->>'decided_at')::timestamptz=b.decided_at + FROM ontology_regression_cases c JOIN phrase_bindings b ON b.kb_id=c.kb_id AND b.phrase='reported alongside' WHERE c.id=$1") + .bind(later).fetch_one(&pool).await?; + assert!(original_time); + let mut tx = pool.begin().await?; + utopia_store::export::provenance_integrity(&mut tx, kb).await?; + tx.rollback().await?; + sqlx::query("DELETE FROM relation_types WHERE id=$1").bind(property).execute(&pool).await?; + assert!(regressions::get(&pool, kb, case_id).await?.is_none()); + assert!(regressions::get(&pool, kb, negative_id).await?.is_some()); + anyhow::Ok(()) + }.await; + sqlx::query("DELETE FROM organizations WHERE id=$1") + .bind(org) + .execute(&pool) + .await?; + result +} + +#[tokio::test] +async fn confirmations_deduplicate_and_serialize_opposite_expectations() -> anyhow::Result<()> { + let Some(Fx { + pool, + org, + kb, + actor, + subject, + object, + property, + }) = Fx::new().await? + else { + return Ok(()); + }; + let result = async { + + let statement = graph::insert_open_statement(&pool, kb, subject, "alongside", + graph::FactObject::Entity(object), None, 1.0).await?.0; + let other = Uuid::now_v7(); + sqlx::query("INSERT INTO users(id,org_id,email,password_hash,display_name) VALUES($1,$2,$3,'fixture','Other')") + .bind(other).bind(org).bind(format!("{other}@example.test")).execute(&pool).await?; + let case = |statement_id, negative: bool, created_by, origin| regressions::NewCase { + statement_id, expected_property_id: (!negative).then_some(property), + expected_direction: (!negative).then_some("forward"), created_by, origin, + }; + let first = regressions::add(&pool, kb, case(statement, true, actor, "person")).await?; + let (a,b) = tokio::join!(regressions::add(&pool,kb,case(statement,true,other,"adoption")), + regressions::add(&pool,kb,case(statement,true,actor,"person"))); + assert_eq!((a?, b?), (first, first)); + let saved = regressions::get(&pool,kb,first).await?.unwrap(); + assert_eq!(saved.created_by,Some(actor)); assert_eq!(saved.origin,"person"); + let fresh = graph::insert_open_statement(&pool,kb,subject,"first confirmations", + graph::FactObject::Entity(object),None,1.0).await?.0; + let (a,b) = tokio::join!(regressions::add(&pool,kb,case(fresh,true,actor,"person")), + regressions::add(&pool,kb,case(fresh,true,other,"adoption"))); + let a = a?; assert_eq!(a,b?); + let winner = regressions::get(&pool,kb,a).await?.unwrap(); + assert!((winner.created_by==Some(actor) && winner.origin=="person") || + (winner.created_by==Some(other) && winner.origin=="adoption")); + // Neither creation order permits a contradictory pair, including a racing pair. + for (i, first_negative) in [Some(true), Some(false), None].into_iter().enumerate() { + let statement = graph::insert_open_statement(&pool,kb,subject,&format!("case {i}"), + graph::FactObject::Entity(object),None,1.0).await?.0; + if let Some(negative) = first_negative { + regressions::add(&pool,kb,case(statement,negative,actor,"person")).await?; + assert!(matches!(regressions::add(&pool,kb,case(statement,!negative,other,"person")).await, + Err(utopia_core::AppError::CodedConflict{code:"conflicting_case_expectation",..}))); + } else { + let (a,b) = tokio::join!(regressions::add(&pool,kb,case(statement,true,actor,"person")), + regressions::add(&pool,kb,case(statement,false,other,"adoption"))); + assert_ne!(a.is_ok(), b.is_ok()); + let error = match a { Err(error) => error, Ok(_) => b.unwrap_err() }; + assert!(matches!(error,utopia_core::AppError::CodedConflict{code:"conflicting_case_expectation",..})); + } + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM ontology_regression_cases WHERE statement_id=$1") + .bind(statement).fetch_one(&pool).await?; + assert_eq!(count,1); + } + // Storage itself rejects half targets and duplicate NULL targets, not just the route. + for (target,direction,code) in [(Some(property),None,"23514"),(None,Some("forward"),"23514"),(None,None,"23505")] { + let err = sqlx::query("INSERT INTO ontology_regression_cases(id,kb_id,statement_id,expected_property_id,expected_direction,origin) VALUES($1,$2,$3,$4,$5,'person')") + .bind(Uuid::now_v7()).bind(kb).bind(statement).bind(target).bind(direction).execute(&pool).await.unwrap_err(); + assert_eq!(err.as_database_error().and_then(|e|e.code()).as_deref(),Some(code)); + } + anyhow::Ok(()) }.await; sqlx::query("DELETE FROM organizations WHERE id=$1") .bind(org) diff --git a/docs/design/ontology.md b/docs/design/ontology.md index 340ea8be1..cf631990e 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -254,6 +254,13 @@ the prompt, a description is read by people and by the aligner. they would bind and the questions they serve; approval writes the element with its structure and re-decides those signatures; every approved element carries regression cases rerun when its definition changes; duplicate properties merged as governance. + A property regression case can also expect no binding (`expected_binding: "none"`, with + both target fields absent or null); omitting the mode keeps the existing bound request. + Only an explicit `none` decision passes a negative case. Bound or undecided decisions fail, + including a conflicting human binding, which remains authoritative in the formal graph. + With no decision the case stays unchecked. Reconfirming a case retains its first creator; + an opposite bound/none expectation for the same statement is refused, including on adoption. + - **Exploration proposes an alignment per table** through `ontology_proposals`, adopted as one thing; `Metric` / `Dimension` retire (#554, #556) [0036]. - Filtering reified-shell relations (`Action`, `Offer`) out of pack import [0012]; Chinese diff --git a/migrations/0115_a_regression_case_can_expect_no_binding.sql b/migrations/0115_a_regression_case_can_expect_no_binding.sql new file mode 100644 index 000000000..33ee09201 --- /dev/null +++ b/migrations/0115_a_regression_case_can_expect_no_binding.sql @@ -0,0 +1,12 @@ +-- 0061: no binding is an expectation, not a synthetic ontology property. +ALTER TABLE ontology_regression_cases + ALTER COLUMN expected_property_id DROP NOT NULL, + ALTER COLUMN expected_direction DROP NOT NULL, + ADD CONSTRAINT ontology_regression_cases_complete_target + CHECK ((expected_property_id IS NULL) = (expected_direction IS NULL)); + +-- The existing unique constraint still deduplicates bound expectations. NULLs need +-- their own uniqueness rule, including concurrent confirmations of the same case. +CREATE UNIQUE INDEX ontology_regression_cases_none + ON ontology_regression_cases(kb_id, statement_id) + WHERE expected_property_id IS NULL; diff --git a/web/src/api.ts b/web/src/api.ts index edea88047..65d87e2da 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1193,8 +1193,9 @@ export interface OntologyRegressionCase { id: string; kb_id: string; statement_id: string; - expected_property_id: string; - expected_direction: "forward" | "reverse"; + expected_binding: "bound" | "none"; + expected_property_id: string | null; + expected_direction: "forward" | "reverse" | null; created_by: string | null; created_at: string; origin: "adoption" | "person"; @@ -1202,6 +1203,7 @@ export interface OntologyRegressionCase { last_result: { passed: boolean; human_bound: boolean; + conflict?: "human_binding" | null; actual_property_id: string | null; actual_direction: "forward" | "reverse" | null; status: "bound" | "none" | "undecided"; @@ -1213,8 +1215,8 @@ export interface OntologyRegressionCase { object_id: string | null; object_label: string | null; object_value: Record | null; - expected_property_label: string; - expected_property_key: string; + expected_property_label: string | null; + expected_property_key: string | null; actual_property_label: string | null; actual_property_key: string | null; created_by_label: string | null; @@ -2265,11 +2267,15 @@ export const api = { ), addOntologyRegression: ( kbId: string, - body: { - statement_id: string; + body: { statement_id: string } & ({ + expected_binding?: "bound"; relation_type_id: string; direction: "forward" | "reverse"; - }, + } | { + expected_binding: "none"; + relation_type_id?: null; + direction?: null; + }), ) => request<{ id: string }>(`/api/v1/kbs/${kbId}/ontology/regressions`, { method: "POST", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a7109fcb7..796d586b3 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -47,6 +47,9 @@ export const en = { /** 服务端校验错误的措辞。key = 服务端给的 code;缺一条就退回英文原句,不会崩。 契约守卫(调错接口才碰得到)刻意不在这里——它们的读者是开发者 */ err: { + bad_case_expectation: "Choose a property and direction, or expect no binding.", + conflicting_case_expectation: "This statement already has the opposite expectation. No case was added.", + bad_email: "That doesn't look like an email address.", password_too_short: "Password must be at least 8 characters.", bad_display_name: "Display name must be 1-64 characters.", @@ -1382,7 +1385,11 @@ export const en = { caseHideEvidence: "Hide source evidence", caseMake: "Add regression case", caseAddTitle: "Add a property regression case", - caseAddHint: "Save the property and direction this open statement should bind to. Its existing alignment decision will be compared with this expectation.", + caseExpectBound: "Bind to a property", + caseNoneHint: "Only an explicit no-binding decision passes. An undecided or bound result fails; no decision remains unchecked.", + caseHumanConflict: "Failed: manual binding conflict", + caseHumanConflictHint: "This case expects no binding, but a person bound the statement. The case fails; the manual binding is retained.", + caseAddHint: "Save whether this open statement should bind to a property or remain unbound. Its alignment decision is compared with that expectation.", caseExpectedProperty: "Expected property", caseDirection: "Direction", caseForward: "Subject → Object", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 9222d4edc..9d315622a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -49,6 +49,9 @@ export const zh: Strings = { docsUrl: "https://utopia.bi/docs", }, err: { + bad_case_expectation: "请选择属性及方向,或选择预期无绑定。", + conflicting_case_expectation: "这条陈述已有相反的预期,未添加新案例。", + bad_email: "这看起来不像一个邮箱地址。", password_too_short: "密码至少 8 位。", bad_display_name: "显示名需要 1–64 个字符。", @@ -1227,7 +1230,11 @@ export const zh: Strings = { caseHideEvidence: "收起原文证据", caseMake: "添加回归案例", caseAddTitle: "添加属性回归案例", - caseAddHint: "保存这条开放陈述应绑定的属性及方向,再将已有的对齐决定与此预期比较。", + caseExpectBound: "绑定到属性", + caseNoneHint: "只有明确判为无绑定才通过。未定或已绑定均失败;尚无判定时保持未检查。", + caseHumanConflict: "失败:与人工绑定冲突", + caseHumanConflictHint: "此案例预期无绑定,但人已将陈述绑定到属性。案例失败,正式人工绑定保留。", + caseAddHint: "保存这条开放陈述应绑定到属性,还是保持无绑定,再将对齐决定与此预期比较。", caseExpectedProperty: "预期属性", caseDirection: "方向", caseForward: "主语 → 宾语", diff --git a/web/src/pages/AddOntologyRegressionDialog.tsx b/web/src/pages/AddOntologyRegressionDialog.tsx index a07a6088a..4bd2ba9eb 100644 --- a/web/src/pages/AddOntologyRegressionDialog.tsx +++ b/web/src/pages/AddOntologyRegressionDialog.tsx @@ -21,12 +21,16 @@ export function AddOntologyRegressionDialog({ queryFn: () => api.ontology(kbId), }); const [property, setProperty] = useState(""); + const [expected, setExpected] = useState<"bound" | "none">("bound"); const [direction, setDirection] = useState<"forward" | "reverse">("forward"); const options = (ontology.data?.relation_types ?? []) .filter((p) => p.kind === (isValue ? "attribute" : "relation")) .map((p) => ({ value: p.id, label: p.label, hint: p.key })); const add = useMutation({ - mutationFn: () => api.addOntologyRegression(kbId, { + mutationFn: () => api.addOntologyRegression(kbId, expected === "none" ? { + statement_id: statementId, + expected_binding: "none", + } : { statement_id: statementId, relation_type_id: property, direction: isValue ? "forward" : direction, @@ -45,20 +49,28 @@ export function AddOntologyRegressionDialog({ closeLabel={S.ui.close} saveLabel={S.ontology.caseAdd} cancelLabel={S.ontology.cancel} - canSave={options.some((p) => p.value === property)} + canSave={expected === "none" || options.some((p) => p.value === property)} busy={add.isPending} onSave={() => add.mutate()} onCancel={onClose} >

{statement}

- {ontology.isError ? ( + + + + {expected === "none" ? ( +

{S.ontology.caseNoneHint}

+ ) : ontology.isError ? (

{(ontology.error as Error).message}

) : ontology.isPending ? ( {S.nav.loading} ) : options.length === 0 ? (

{S.ontology.caseNoProperties}

) : ( -
+
diff --git a/web/src/pages/OntologyRegressionsPanel.test.ts b/web/src/pages/OntologyRegressionsPanel.test.ts index d8622a822..649d9b207 100644 --- a/web/src/pages/OntologyRegressionsPanel.test.ts +++ b/web/src/pages/OntologyRegressionsPanel.test.ts @@ -8,7 +8,7 @@ import { OntologyRegressionsPanel } from "./OntologyRegressionsPanel"; const base: OntologyRegressionCase = { id: "case", kb_id: "kb", statement_id: "statement", - expected_property_id: "works-for", expected_direction: "forward", + expected_binding: "bound", expected_property_id: "works-for", expected_direction: "forward", created_by: "actor", created_at: "2026-10-10T00:00:00Z", origin: "adoption", last_checked_at: "2026-10-10T01:00:00Z", last_result: { @@ -63,3 +63,24 @@ describe("stored property regression comparisons", () => { expect(html).not.toContain(`>${S.ontology.casePassed}<`); }); }); + + +it("shows negative expectations and a failing manual conflict without a fabricated property or direction", () => { + const negative: OntologyRegressionCase = { + ...base, expected_binding: "none", expected_property_id: null, expected_direction: null, + expected_property_label: null, expected_property_key: null, actual_property_label: null, + last_result: { ...base.last_result!, status: "none", actual_property_id: null, actual_direction: null }, + }; + const passing = render([negative]); + expect(passing).toContain(S.ontology.caseUnbound); + expect(passing).toContain(S.ontology.casePassed); + expect(passing).not.toContain(S.ontology.caseForward); + const conflict = render([{ ...negative, actual_property_label: "Works for", last_result: { + ...base.last_result!, passed: false, human_bound: false, conflict: "human_binding", + } }]); + expect(conflict).toContain(S.ontology.caseHumanConflict); + expect(conflict).toContain(S.ontology.caseHumanConflictHint); + expect(conflict).toContain("Works for"); + expect(conflict).not.toContain(S.ontology.caseHumanHint); + expect(conflict).not.toContain(`>${S.ontology.casePassed}<`); +}); diff --git a/web/src/pages/OntologyRegressionsPanel.tsx b/web/src/pages/OntologyRegressionsPanel.tsx index 8a73aa8b8..51a05a252 100644 --- a/web/src/pages/OntologyRegressionsPanel.tsx +++ b/web/src/pages/OntologyRegressionsPanel.tsx @@ -100,8 +100,8 @@ function CaseRow({ kbId, item }: { kbId: string; item: OntologyRegressionCase })
-
{item.expected_property_label}
-
{directionText(item.expected_direction)}
+
{item.expected_binding === "none" ? S.ontology.caseUnbound : item.expected_property_label}
+ {item.expected_direction &&
{directionText(item.expected_direction)}
}
@@ -119,11 +119,14 @@ function CaseRow({ kbId, item }: { kbId: string; item: OntologyRegressionCase }) - {!result ? S.ontology.caseNotChecked : result.human_bound ? S.ontology.caseHumanBound : result.passed ? S.ontology.casePassed : S.ontology.caseMismatch} + {!result ? S.ontology.caseNotChecked : result.conflict === "human_binding" ? S.ontology.caseHumanConflict : result.human_bound ? S.ontology.caseHumanBound : result.passed ? S.ontology.casePassed : S.ontology.caseMismatch} {result?.human_bound && (

{S.ontology.caseHumanHint}

)} + {result?.conflict === "human_binding" && ( +

{S.ontology.caseHumanConflictHint}

+ )} {item.last_checked_at ? localDateTime(item.last_checked_at) : "—"} From 48044311156121f48b827db7688493faaee40104 Mon Sep 17 00:00:00 2001 From: Virtue039 Date: Wed, 21 Oct 2026 14:22:48 +0800 Subject: [PATCH 2/3] Let negative regression cases report conflicts without vetoing adoption Signed-off-by: Virtue039 --- .../api/ontology_regression_routes_tests.rs | 7 +- crates/utopia-server/src/ontology_agent.rs | 46 ++--- .../utopia-server/src/ontology_agent_tests.rs | 175 ++++++++++++------ .../utopia-store/src/ontology_regressions.rs | 33 ---- .../tests/store/ontology_regressions.rs | 166 ++++++++--------- docs/design/ontology.md | 5 +- ...regression_case_can_expect_no_binding.sql} | 0 web/src/i18n/en.ts | 1 - web/src/i18n/zh.ts | 1 - 9 files changed, 219 insertions(+), 215 deletions(-) rename migrations/{0117_a_regression_case_can_expect_no_binding.sql => 0118_a_regression_case_can_expect_no_binding.sql} (100%) diff --git a/crates/utopia-server/src/api/ontology_regression_routes_tests.rs b/crates/utopia-server/src/api/ontology_regression_routes_tests.rs index 7f347c25d..fdcf9291b 100644 --- a/crates/utopia-server/src/api/ontology_regression_routes_tests.rs +++ b/crates/utopia-server/src/api/ontology_regression_routes_tests.rs @@ -198,10 +198,13 @@ async fn editor_adds_regression_case_while_viewer_and_cross_kb_statement_are_rej json!({"statement_id":negative,"relation_type_id":property,"direction":"forward"}), ] { let response = post_body(&app, kb, &editor_token, request).await?; - assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(response.status(), StatusCode::CREATED); let body: serde_json::Value = serde_json::from_slice(&to_bytes(response.into_body(), 65536).await?)?; - assert_eq!(body["code"], "conflicting_case_expectation"); + assert_ne!( + body["id"], negative_id, + "opposite cases do not overwrite a confirmation" + ); } let sig = utopia_store::phrase_bindings::signatures(&pool, kb) .await? diff --git a/crates/utopia-server/src/ontology_agent.rs b/crates/utopia-server/src/ontology_agent.rs index 972cf85f8..fe844de1d 100644 --- a/crates/utopia-server/src/ontology_agent.rs +++ b/crates/utopia-server/src/ontology_agent.rs @@ -1062,12 +1062,6 @@ async fn adopt_on( return Err(AppError::NotFound); }; let p = &locked.proposal; - // Binding writes also update existing cases. Take their confirmation locks first, - // then let the enclosing transaction roll back any opposite-expectation conflict. - for (statement, _) in regression_examples(p, &[])? { - utopia_store::ontology_regressions::lock_statement(tx, kb_id, statement).await?; - } - // 人改过几格:决定 5 的第二个数要它 let edited = edits.label.is_some() || edits.description.is_some() @@ -1209,10 +1203,19 @@ async fn embed_then_align( Ok(()) } -fn regression_examples( - proposal: &StoredProposal, +/// 回归用例和采纳的状态(决定 4),在采纳的事务里。人改过没有(决定 5 的第二个数)和状态同一条 +/// UPDATE 写下:从前是采纳提交之后的另一条语句。复用留着没写的形状(`skipped`)不写用例:人定得 +/// 和提案不一样;人后来定得一样的照写 +async fn finish_adoption( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, + locked: &Locked, + property_id: Uuid, + actor: Uuid, + edited: bool, skipped: &[Value], -) -> Result, AppError> { +) -> Result<(), AppError> { + let proposal = &locked.proposal; let mut examples = Vec::new(); let property = matches!( proposal.section.as_str(), @@ -1241,28 +1244,11 @@ fn regression_examples( examples.extend(ids.into_iter().map(|statement| (statement, direction))); } } - // Multiple proposals can share examples; acquire case locks in the same order. - examples.sort_unstable(); - examples.dedup(); - Ok(examples) -} - -/// 回归用例和采纳的状态(决定 4),在采纳的事务里。人改过没有(决定 5 的第二个数)和状态同一条 -/// UPDATE 写下:从前是采纳提交之后的另一条语句。复用留着没写的形状(`skipped`)不写用例:人定得 -/// 和提案不一样;人后来定得一样的照写 -async fn finish_adoption( - tx: &mut Transaction<'_, Postgres>, - kb_id: Uuid, - locked: &Locked, - property_id: Uuid, - actor: Uuid, - edited: bool, - skipped: &[Value], -) -> Result<(), AppError> { - let examples = regression_examples(&locked.proposal, skipped)?; for (statement_id, direction) in examples { - // Removed examples cannot be checked; never substitute another statement. - let live: Option = sqlx::query_scalar("SELECT id FROM facts WHERE kb_id=$1 AND id=$2 AND layer='open' AND invalidated_at IS NULL FOR SHARE") + // A negative case observes disagreement; it must not veto adoption or gain an + // automatic positive counterpart. Removed examples are skipped too, never replaced. + let live: Option = sqlx::query_scalar("SELECT f.id FROM facts f WHERE f.kb_id=$1 AND f.id=$2 AND f.layer='open' AND f.invalidated_at IS NULL + AND NOT EXISTS (SELECT 1 FROM ontology_regression_cases c WHERE c.kb_id=f.kb_id AND c.statement_id=f.id AND c.expected_property_id IS NULL) FOR SHARE OF f") .bind(kb_id).bind(statement_id).fetch_optional(&mut **tx).await?; if live.is_none() { continue; diff --git a/crates/utopia-server/src/ontology_agent_tests.rs b/crates/utopia-server/src/ontology_agent_tests.rs index 8d444c3e4..e7ac8ba9e 100644 --- a/crates/utopia-server/src/ontology_agent_tests.rs +++ b/crates/utopia-server/src/ontology_agent_tests.rs @@ -1216,67 +1216,128 @@ async fn a_class_deleted_during_adoption_answers_unknown_class() -> anyhow::Resu } #[tokio::test] -async fn a_negative_example_rolls_back_property_creation_and_reuse() -> anyhow::Result<()> { +async fn adoption_skips_negative_examples_without_vetoing_creation_or_reuse() -> anyhow::Result<()> +{ for (section, key) in [("relation_types", "founded_in"), ("map_to", "located_in")] { - let Some(f) = Fx::new(Vec::new()).await? else { - return Ok(()); - }; - let result = async { - f.decide_none().await?; - let shape = f.shape("founded in").await?; - let statement_id = serde_json::from_value(shape["example_statement_ids"][0].clone())?; - let case = utopia_store::ontology_regressions::add( - &f.pool, - f.kb, - utopia_store::ontology_regressions::NewCase { - statement_id, - expected_property_id: None, - expected_direction: None, - created_by: f.user, - origin: "person", - }, - ) - .await?; - let before = utopia_store::ontology_regressions::get(&f.pool, f.kb, case) - .await? - .unwrap(); - f.seed( - section, - key, - if section == "map_to" { - json!({"kind":"relation"}) - } else { - founded_in() - }, - json!({"phrases":[shape]}), - ) - .await?; - let (status, body) = f - .post( - "/ontology/proposals/adopt", - json!({"section":section,"key":key}), + for ordinary_example in [false, true] { + let Some(f) = Fx::new(Vec::new()).await? else { + return Ok(()); + }; + let result = async { + f.decide_none().await?; + let negative = f.shape("founded in").await?; + let statement_id = + serde_json::from_value(negative["example_statement_ids"][0].clone())?; + let case = utopia_store::ontology_regressions::add( + &f.pool, + f.kb, + utopia_store::ontology_regressions::NewCase { + statement_id, + expected_property_id: None, + expected_direction: None, + created_by: f.user, + origin: "person", + }, + ) + .await?; + let before = utopia_store::ontology_regressions::get(&f.pool, f.kb, case) + .await? + .unwrap(); + let mut shapes = vec![negative]; + if ordinary_example { + shapes.push(f.shape("located in").await?); + } + f.seed( + section, + key, + if section == "map_to" { + json!({"kind":"relation"}) + } else { + founded_in() + }, + json!({"phrases":shapes}), ) .await?; - assert_eq!(status, StatusCode::CONFLICT, "{body}"); - assert_eq!(body["code"], "conflicting_case_expectation"); - assert_eq!( - f.left_by(key).await?, - (section == "map_to", "open".into(), 0, 1) - ); - assert!(phrase_bindings::bindings(&f.pool, f.kb) - .await? - .iter() - .all(|b| b.status == "none" && b.decided_by == "agent")); - let after = utopia_store::ontology_regressions::get(&f.pool, f.kb, case) - .await? - .unwrap(); - assert_eq!(before.last_result, after.last_result); - assert_eq!(before.last_checked_at, after.last_checked_at); - anyhow::Ok(()) + let (status, body) = f + .post( + "/ontology/proposals/adopt", + json!({"section":section,"key":key}), + ) + .await?; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + f.left_by(key).await?, + ( + true, + "adopted".into(), + if section == "map_to" { 1 } else { 2 }, + 1 + i64::from(ordinary_example) + ) + ); + let positive: i64 = sqlx::query_scalar( + "SELECT count(*) FROM ontology_regression_cases + WHERE kb_id=$1 AND statement_id=$2 AND expected_property_id IS NOT NULL", + ) + .bind(f.kb) + .bind(statement_id) + .fetch_one(&f.pool) + .await?; + assert_eq!( + positive, 0, + "negative examples do not gain an automatic positive case" + ); + let property: Uuid = serde_json::from_value(body["id"].clone())?; + // Creation queues alignment; exercise that later committed decision without a model. + // Reuse has already written the person's binding in the adoption transaction. + if section != "map_to" { + let sig = phrase_bindings::signatures(&f.pool, f.kb) + .await? + .into_iter() + .find(|s| s.phrase == "founded in") + .unwrap(); + phrase_bindings::decide( + &f.pool, + f.kb, + &sig, + Decision { + relation_type_id: Some(property), + direction: Some("forward"), + status: "bound", + decided_by: "agent", + votes: &Value::Null, + basis: None, + marks: None, + marks_asked: false, + }, + ) + .await?; + } + let after = utopia_store::ontology_regressions::get(&f.pool, f.kb, case) + .await? + .unwrap(); + assert_eq!( + (after.created_by, after.created_at, after.origin), + (before.created_by, before.created_at, before.origin) + ); + let report = after.last_result.unwrap(); + assert_eq!(report["passed"], false); + assert_eq!(report["actual_property_id"], json!(property)); + assert_eq!( + report["conflict"], + if section == "map_to" { + json!("human_binding") + } else { + Value::Null + } + ); + assert!(f.model.requests.lock().unwrap().is_empty()); + assert_eq!(f.model.embeds.load(Ordering::SeqCst), 0); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + result?; } - .await; - f.cleanup().await?; - result?; } Ok(()) } diff --git a/crates/utopia-store/src/ontology_regressions.rs b/crates/utopia-store/src/ontology_regressions.rs index d64084e7f..953f6c3c7 100644 --- a/crates/utopia-store/src/ontology_regressions.rs +++ b/crates/utopia-store/src/ontology_regressions.rs @@ -103,20 +103,6 @@ pub async fn add(pool: &PgPool, kb_id: Uuid, case: NewCase<'_>) -> AppResult AppResult<()> { - sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))") - .bind(format!("ontology_regression:{kb_id}:{statement_id}")) - .execute(conn) - .await?; - Ok(()) -} - /// Adoption calls this in its own transaction so marking a proposal adopted and keeping /// its examples cannot commit separately. Duplicate requests retain the first actor/origin. pub async fn add_on(conn: &mut PgConnection, kb_id: Uuid, case: NewCase<'_>) -> AppResult { @@ -145,25 +131,6 @@ pub async fn add_on(conn: &mut PgConnection, kb_id: Uuid, case: NewCase<'_>) -> .fetch_optional(&mut *conn) .await?; let value = value.ok_or(AppError::NotFound)?; - // Serialize opposite expectations too: separate unique indexes alone would allow - // a concurrent bound/none pair. Retries must retain the first actor and origin. - lock_statement(conn, kb_id, case.statement_id).await?; - let conflicting: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM ontology_regression_cases WHERE kb_id=$1 AND statement_id=$2 - AND (expected_property_id IS NULL) <> $3)", - ) - .bind(kb_id) - .bind(case.statement_id) - .bind(case.expected_property_id.is_none()) - .fetch_one(&mut *conn) - .await?; - if conflicting { - return Err(AppError::CodedConflict { - code: "conflicting_case_expectation", - message: "This statement already has the opposite expectation. No case was added." - .into(), - }); - } if let Some(property) = case.expected_property_id { let kind: Option = sqlx::query_scalar( "SELECT kind FROM relation_types WHERE kb_id=$1 AND id=$2 FOR SHARE", diff --git a/crates/utopia-store/tests/store/ontology_regressions.rs b/crates/utopia-store/tests/store/ontology_regressions.rs index 7627c310b..c1c265ca4 100644 --- a/crates/utopia-store/tests/store/ontology_regressions.rs +++ b/crates/utopia-store/tests/store/ontology_regressions.rs @@ -2,6 +2,73 @@ use sqlx::PgPool; use utopia_store::{graph, ontology_regressions as regressions, phrase_bindings}; use uuid::Uuid; +#[tokio::test] +async fn committed_decisions_report_matches_and_human_bindings() -> 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 (org, workspace, kb, actor, subject, object, property) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + sqlx::query("INSERT INTO organizations(id,name) VALUES($1,'regression-cases')") + .bind(org) + .execute(&pool) + .await?; + let result = async { + sqlx::query("INSERT INTO workspaces(id,org_id,name) VALUES($1,$2,'regression-cases')") + .bind(workspace).bind(org).execute(&pool).await?; + sqlx::query("INSERT INTO knowledge_bases(id,workspace_id,name) VALUES($1,$2,'regression-cases')") + .bind(kb).bind(workspace).execute(&pool).await?; + sqlx::query("INSERT INTO users(id,org_id,email,password_hash,display_name) VALUES($1,$2,$3,'fixture','Reviewer')") + .bind(actor).bind(org).bind(format!("{actor}@example.test")).execute(&pool).await?; + for (id, name) in [(subject, "A"), (object, "B")] { + sqlx::query("INSERT INTO entities(id,kb_id,canonical_name) VALUES($1,$2,$3)") + .bind(id).bind(kb).bind(name).execute(&pool).await?; + } + sqlx::query("INSERT INTO relation_types(id,kb_id,key,label) VALUES($1,$2,'acquired','acquired')") + .bind(property).bind(kb).execute(&pool).await?; + let statement_id = graph::insert_open_statement( + &pool, kb, subject, "acquired", graph::FactObject::Entity(object), None, 1.0, + ).await?.0; + let case_id = regressions::add(&pool, kb, regressions::NewCase { + statement_id, expected_property_id: Some(property), expected_direction: Some("forward"), + created_by: actor, origin: "person", + }).await?; + let signature = phrase_bindings::signatures(&pool, kb).await?.remove(0); + for (direction, decided_by, passed, human_bound) in [ + ("forward", "agent", true, false), + ("reverse", "agent", false, false), + ("reverse", "person", true, true), + ] { + phrase_bindings::decide(&pool, kb, &signature, phrase_bindings::Decision { + relation_type_id: Some(property), direction: Some(direction), status: "bound", + decided_by, votes: &serde_json::Value::Null, basis: None, + marks: None, marks_asked: false, + }).await?; + let case = regressions::get(&pool, kb, case_id).await?.unwrap(); + let report = case.last_result.unwrap(); + anyhow::ensure!(report["passed"] == passed); + anyhow::ensure!(report["human_bound"] == human_bound); + anyhow::ensure!(report["actual_direction"] == direction); + anyhow::ensure!(case.last_checked_at.is_some()); + } + Ok(()) + }.await; + sqlx::query("DELETE FROM organizations WHERE id=$1") + .bind(org) + .execute(&pool) + .await?; + result +} + struct Fx { pool: PgPool, org: Uuid, @@ -72,83 +139,6 @@ impl Fx { } } -#[tokio::test] -async fn committed_decisions_report_matches_and_human_bindings() -> anyhow::Result<()> { - let Some(Fx { - pool, - org, - kb, - actor, - subject, - object, - property, - }) = Fx::new().await? - else { - return Ok(()); - }; - let result = async { - let statement_id = graph::insert_open_statement( - &pool, - kb, - subject, - "acquired", - graph::FactObject::Entity(object), - None, - 1.0, - ) - .await? - .0; - let case_id = regressions::add( - &pool, - kb, - regressions::NewCase { - statement_id, - expected_property_id: Some(property), - expected_direction: Some("forward"), - created_by: actor, - origin: "person", - }, - ) - .await?; - let signature = phrase_bindings::signatures(&pool, kb).await?.remove(0); - for (direction, decided_by, passed, human_bound) in [ - ("forward", "agent", true, false), - ("reverse", "agent", false, false), - ("reverse", "person", true, true), - ] { - phrase_bindings::decide( - &pool, - kb, - &signature, - phrase_bindings::Decision { - relation_type_id: Some(property), - direction: Some(direction), - status: "bound", - decided_by, - votes: &serde_json::Value::Null, - basis: None, - marks: None, - marks_asked: false, - }, - ) - .await?; - let case = regressions::get(&pool, kb, case_id).await?.unwrap(); - let report = case.last_result.unwrap(); - anyhow::ensure!(report["passed"] == passed); - anyhow::ensure!(report["human_bound"] == human_bound); - anyhow::ensure!(report["actual_direction"] == direction); - anyhow::ensure!(case.last_checked_at.is_some()); - } - anyhow::Ok(()) - } - .await; - sqlx::query("DELETE FROM organizations WHERE id=$1") - .bind(org) - .execute(&pool) - .await?; - result -} - #[tokio::test] async fn negative_cases_fail_on_human_bindings_and_pass_only_explicit_none() -> anyhow::Result<()> { let Some(Fx { @@ -230,7 +220,7 @@ async fn negative_cases_fail_on_human_bindings_and_pass_only_explicit_none() -> } #[tokio::test] -async fn confirmations_deduplicate_and_serialize_opposite_expectations() -> anyhow::Result<()> { +async fn confirmations_deduplicate_without_vetoing_opposite_expectations() -> anyhow::Result<()> { let Some(Fx { pool, org, @@ -255,7 +245,7 @@ async fn confirmations_deduplicate_and_serialize_opposite_expectations() -> anyh expected_direction: (!negative).then_some("forward"), created_by, origin, }; let first = regressions::add(&pool, kb, case(statement, true, actor, "person")).await?; - let (a,b) = tokio::join!(regressions::add(&pool,kb,case(statement,true,other,"adoption")), + let (a,b) = tokio::join!(regressions::add(&pool,kb,case(statement,true,other,"person")), regressions::add(&pool,kb,case(statement,true,actor,"person"))); assert_eq!((a?, b?), (first, first)); let saved = regressions::get(&pool,kb,first).await?.unwrap(); @@ -263,29 +253,27 @@ async fn confirmations_deduplicate_and_serialize_opposite_expectations() -> anyh let fresh = graph::insert_open_statement(&pool,kb,subject,"first confirmations", graph::FactObject::Entity(object),None,1.0).await?.0; let (a,b) = tokio::join!(regressions::add(&pool,kb,case(fresh,true,actor,"person")), - regressions::add(&pool,kb,case(fresh,true,other,"adoption"))); + regressions::add(&pool,kb,case(fresh,true,other,"person"))); let a = a?; assert_eq!(a,b?); let winner = regressions::get(&pool,kb,a).await?.unwrap(); assert!((winner.created_by==Some(actor) && winner.origin=="person") || - (winner.created_by==Some(other) && winner.origin=="adoption")); - // Neither creation order permits a contradictory pair, including a racing pair. + (winner.created_by==Some(other) && winner.origin=="person")); + // Cases observe decisions: opposite expectations can coexist in either order + // and under concurrent confirmation, without rewriting either person's case. for (i, first_negative) in [Some(true), Some(false), None].into_iter().enumerate() { let statement = graph::insert_open_statement(&pool,kb,subject,&format!("case {i}"), graph::FactObject::Entity(object),None,1.0).await?.0; if let Some(negative) = first_negative { regressions::add(&pool,kb,case(statement,negative,actor,"person")).await?; - assert!(matches!(regressions::add(&pool,kb,case(statement,!negative,other,"person")).await, - Err(utopia_core::AppError::CodedConflict{code:"conflicting_case_expectation",..}))); + regressions::add(&pool,kb,case(statement,!negative,other,"person")).await?; } else { let (a,b) = tokio::join!(regressions::add(&pool,kb,case(statement,true,actor,"person")), - regressions::add(&pool,kb,case(statement,false,other,"adoption"))); - assert_ne!(a.is_ok(), b.is_ok()); - let error = match a { Err(error) => error, Ok(_) => b.unwrap_err() }; - assert!(matches!(error,utopia_core::AppError::CodedConflict{code:"conflicting_case_expectation",..})); + regressions::add(&pool,kb,case(statement,false,other,"person"))); + assert_ne!(a?, b?); } let count: i64 = sqlx::query_scalar("SELECT count(*) FROM ontology_regression_cases WHERE statement_id=$1") .bind(statement).fetch_one(&pool).await?; - assert_eq!(count,1); + assert_eq!(count,2); } // Storage itself rejects half targets and duplicate NULL targets, not just the route. for (target,direction,code) in [(Some(property),None,"23514"),(None,Some("forward"),"23514"),(None,None,"23505")] { diff --git a/docs/design/ontology.md b/docs/design/ontology.md index cf631990e..cf1c3ae03 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -258,8 +258,9 @@ the prompt, a description is read by people and by the aligner. both target fields absent or null); omitting the mode keeps the existing bound request. Only an explicit `none` decision passes a negative case. Bound or undecided decisions fail, including a conflicting human binding, which remains authoritative in the formal graph. - With no decision the case stays unchecked. Reconfirming a case retains its first creator; - an opposite bound/none expectation for the same statement is refused, including on adoption. + With no decision the case stays unchecked. Reconfirming a case retains its first creator. + Adoption skips positive case registration for an example with a negative case; the + existing case reports any disagreement after alignment without blocking adoption. - **Exploration proposes an alignment per table** through `ontology_proposals`, adopted as one thing; `Metric` / `Dimension` retire (#554, #556) [0036]. diff --git a/migrations/0117_a_regression_case_can_expect_no_binding.sql b/migrations/0118_a_regression_case_can_expect_no_binding.sql similarity index 100% rename from migrations/0117_a_regression_case_can_expect_no_binding.sql rename to migrations/0118_a_regression_case_can_expect_no_binding.sql diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 204981c19..284c299e9 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -48,7 +48,6 @@ export const en = { 契约守卫(调错接口才碰得到)刻意不在这里——它们的读者是开发者 */ err: { bad_case_expectation: "Choose a property and direction, or expect no binding.", - conflicting_case_expectation: "This statement already has the opposite expectation. No case was added.", bad_email: "That doesn't look like an email address.", password_too_short: "Password must be at least 8 characters.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c96f18616..85442f341 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -50,7 +50,6 @@ export const zh: Strings = { }, err: { bad_case_expectation: "请选择属性及方向,或选择预期无绑定。", - conflicting_case_expectation: "这条陈述已有相反的预期,未添加新案例。", bad_email: "这看起来不像一个邮箱地址。", password_too_short: "密码至少 8 位。", From fdbc1893307ff4ac775af4f968a9a04e7a7b764d Mon Sep 17 00:00:00 2001 From: Virtue039 Date: Thu, 22 Oct 2026 14:42:08 +0800 Subject: [PATCH 3/3] Keep MCP test teardown from racing index background work Signed-off-by: Virtue039 --- crates/utopia-server/src/api/mcp_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/utopia-server/src/api/mcp_tests.rs b/crates/utopia-server/src/api/mcp_tests.rs index 1c6f16659..ee16cd1b2 100644 --- a/crates/utopia-server/src/api/mcp_tests.rs +++ b/crates/utopia-server/src/api/mcp_tests.rs @@ -193,7 +193,17 @@ impl Fixture { .await?; let dir = self.dir.clone(); drop(self); - std::fs::remove_dir_all(dir)?; + // Tantivy can finish a background file operation after the state is dropped. + // Retry only that teardown race; persistent or unrelated IO errors still fail. + for attempt in 0..10 { + match std::fs::remove_dir_all(&dir) { + Ok(()) => break, + Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty && attempt < 9 => { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + Err(e) => return Err(e.into()), + } + } Ok(()) } }