diff --git a/crates/utopia-ingest/src/ontology_rdf.rs b/crates/utopia-ingest/src/ontology_rdf.rs index 00090a0cf..824e5a0d0 100644 --- a/crates/utopia-ingest/src/ontology_rdf.rs +++ b/crates/utopia-ingest/src/ontology_rdf.rs @@ -62,6 +62,10 @@ pub struct OwlProperty { /// `author rangeIncludes Organization, Person` 就会被读成 /// "必须既是组织又是人",然后降级成 text——一条边就这么没了 pub ranges_union: bool, + /// `utopia:temporal` 的值:`state` / `event` / `eternal`(0031)。导出端早就写这一条, + /// 导入端从前不读——一份本体自己说「这条关系是一个时刻」,导进来还是 `state`。 + /// 没写或写了别的值都是 `None`,落库时按 `state`;数据属性读了也不用 + pub temporal: Option, } /// 一次解析的结果。`unprojected` 是**报告**不是错误——见模块文档。 @@ -85,6 +89,8 @@ pub type VocabDatatypes = BTreeMap; const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const RDFS: &str = "http://www.w3.org/2000/01/rdf-schema#"; const OWL: &str = "http://www.w3.org/2002/07/owl#"; +/// 我们自己的注解命名空间,与导出端(`utopia-server::rdf`)同一个 +const UTOPIA_NS: &str = "urn:utopia:ns:"; /// schema.org 自己那套 domain/range。**不是标准词汇**,所以写死在这里, /// 但值得认:schema.org 及派生词汇表一个 `rdfs:domain` 都没有, @@ -342,6 +348,9 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result let mut ranges: BTreeMap> = BTreeMap::new(); // rangeIncludes 用过的属性,它的 range 按并集读 let mut union_ranged: BTreeSet = BTreeSet::new(); + // `utopia:temporal` 说的时间语义;只认三个值 + let mut temporal: BTreeMap = BTreeMap::new(); + let utopia_temporal = format!("{UTOPIA_NS}temporal"); let known = |p: &str| { p == RDF_TYPE || p == format!("{RDFS}label") @@ -365,6 +374,12 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result }; for t in &triples { let p = t.predicate.as_str(); + // 导出端写的就是这三个字面值(0031)。别的值不猜:落到下面按谓词报告,落库按 `state` + let declared_temporal = t + .object_lit + .as_ref() + .map(|(v, _)| v.as_str()) + .filter(|v| p == utopia_temporal && matches!(*v, "state" | "event" | "eternal")); if p == format!("{RDFS}label") { if let Some(l) = &t.object_lit { labels.entry(t.subject.clone()).or_default().push(l.clone()); @@ -444,6 +459,8 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result ranges.entry(t.subject.clone()).or_default().push(o.clone()); union_ranged.insert(t.subject.clone()); } + } else if let Some(v) = declared_temporal { + temporal.entry(t.subject.clone()).or_insert(v.to_string()); } else if !known(p) { // 报告而不是丢弃:预览页要能说清"这个文件里还有什么我们没消费" *proj.unprojected.entry(p.to_string()).or_insert(0) += 1; @@ -582,6 +599,7 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result domains: domains.get(iri).cloned().unwrap_or_default(), ranges: rs, ranges_union: union_ranged.contains(iri), + temporal: temporal.get(iri).cloned(), iri: iri.clone(), }); } @@ -1716,6 +1734,42 @@ schema:employees a rdf:Property ; rdfs:label "employees" ; "**认了就不该再算进「暂未投影」**" ); } + + /// `utopia:temporal` 要被读出来(0031)。导出端一直写它,导入端从前不读: + /// 一份本体自己说「这条关系是一个时刻」,导进来还是 `state`,#1242 的那条 + /// 「非 state 的关系不经模型就能绑」对它永远不成立。 + #[test] + fn a_property_annotated_with_utopia_temporal_keeps_the_value_it_declared() { + let ttl = r#" +@prefix owl: . +@prefix rdfs: . +@prefix utopia: . +@prefix ex: . + +ex:Person a owl:Class . +ex:attended a owl:ObjectProperty ; rdfs:label "attended" ; utopia:temporal "event" . +ex:born_of a owl:ObjectProperty ; rdfs:label "born of" ; utopia:temporal "eternal" . +ex:works_at a owl:ObjectProperty ; rdfs:label "works at" . +ex:met a owl:ObjectProperty ; rdfs:label "met" ; utopia:temporal "sometimes" . +"#; + let p = project(ttl.as_bytes(), RdfFormat::Turtle).expect("解析"); + let by = |k: &str| { + p.properties + .iter() + .find(|x| x.key == k) + .unwrap_or_else(|| panic!("没有 {k}")) + }; + assert_eq!(by("attended").temporal.as_deref(), Some("event")); + assert_eq!(by("born_of").temporal.as_deref(), Some("eternal")); + assert_eq!( + by("works_at").temporal, + None, + "没写就是没写,落库再按 state" + ); + // 三个值以外的不猜:留空并按谓词报告出来,只有 `met` 那一条,预览页能说清它没进来 + assert_eq!(by("met").temporal, None); + assert_eq!(p.unprojected.get("urn:utopia:ns:temporal"), Some(&1)); + } } #[cfg(test)] diff --git a/crates/utopia-server/src/api/token_setup_tests.rs b/crates/utopia-server/src/api/token_setup_tests.rs index bf794868b..05b16b4ba 100644 --- a/crates/utopia-server/src/api/token_setup_tests.rs +++ b/crates/utopia-server/src/api/token_setup_tests.rs @@ -470,3 +470,97 @@ async fn importing_an_ontology_queues_alignment_behind_the_index_refresh() -> an f.cleanup().await?; result } + +/// 导入读 `utopia:temporal`(0031)。导出端一直写这一条,导入端从前不读:一份本体 +/// 说「这条关系是一个时刻」,导进来照样 `state`,#1242 的「非 state 的关系不经模型 +/// 就能绑」对导入的本体永远不成立。新建的照本体建;重导入时本体说了的改过来, +/// 没说的不动——与 domain/range 同一个道理。真改了推 `updated_at`,与页面上改一样 +#[tokio::test] +async fn an_imported_property_keeps_the_temporal_it_declares() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let result = async { + let kb = f.seeded_kb("temporal").await?; + let token = f.token(f.admin, "write", None).await; + const PREFIXES: &str = "@prefix owl: .\n\ + @prefix rdfs: .\n\ + @prefix utopia: .\n\ + @prefix ex: .\n"; + let works_at = || { + let pool = f.pool.clone(); + async move { + sqlx::query_as::<_, (String, chrono::DateTime)>( + "SELECT temporal, updated_at FROM relation_types + WHERE kb_id = $1 AND iri = 'http://example.org/worksAt'", + ) + .bind(kb) + .fetch_one(&pool) + .await + } + }; + + // 第一版没有注解:worksAt 建成 state + let first = + format!("{PREFIXES}ex:worksAt a owl:ObjectProperty ; rdfs:label \"works at\" .\n"); + let (status, body) = f.import_turtle(kb, &token, &first).await?; + anyhow::ensure!(status == StatusCode::OK, "{status} {body}"); + let (temporal, created) = works_at().await?; + anyhow::ensure!(temporal == "state"); + + // 第二版:worksAt 加了注解,attended 是带注解新建的 + let second = format!( + "{PREFIXES}ex:worksAt a owl:ObjectProperty ; rdfs:label \"works at\" ; \ + utopia:temporal \"eternal\" .\n\ + ex:attended a owl:ObjectProperty ; rdfs:label \"attended\" ; \ + utopia:temporal \"event\" .\n" + ); + let (status, body) = f.import_turtle(kb, &token, &second).await?; + anyhow::ensure!(status == StatusCode::OK, "{status} {body}"); + let (temporal, changed) = works_at().await?; + anyhow::ensure!( + temporal == "eternal", + "a re-import writes the temporal the file now declares" + ); + anyhow::ensure!( + changed > created, + "a temporal changed by import moves updated_at" + ); + let attended: String = sqlx::query_scalar( + "SELECT temporal FROM relation_types + WHERE kb_id = $1 AND iri = 'http://example.org/attended'", + ) + .bind(kb) + .fetch_one(&f.pool) + .await?; + anyhow::ensure!( + attended == "event", + "a fresh import creates the relation with the temporal it declares" + ); + + // 第三版把注解去掉:没说就不动,人在页面上改过的也不会被重导入抹掉 + let (status, body) = f.import_turtle(kb, &token, &first).await?; + anyhow::ensure!(status == StatusCode::OK, "{status} {body}"); + anyhow::ensure!( + works_at().await? == ("eternal".to_string(), changed), + "an unannotated re-import leaves temporal and updated_at alone" + ); + + // 第四版说回 state:导出端写 state,所以一份导出重导入能把关系带回 state + let back = format!( + "{PREFIXES}ex:worksAt a owl:ObjectProperty ; rdfs:label \"works at\" ; \ + utopia:temporal \"state\" .\n" + ); + let (status, body) = f.import_turtle(kb, &token, &back).await?; + anyhow::ensure!(status == StatusCode::OK, "{status} {body}"); + let (temporal, returned) = works_at().await?; + anyhow::ensure!( + temporal == "state" && returned > changed, + "a re-import declaring state returns the relation to state" + ); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + result +} diff --git a/crates/utopia-server/src/owl_import.rs b/crates/utopia-server/src/owl_import.rs index f5443ba0f..a714e7377 100644 --- a/crates/utopia-server/src/owl_import.rs +++ b/crates/utopia-server/src/owl_import.rs @@ -610,6 +610,7 @@ pub async fn apply( &p.description, &domains, &ranges, + p.temporal.as_deref(), ) .await? { @@ -631,6 +632,8 @@ pub async fn apply( symmetric: p.symmetric, asymmetric: p.asymmetric, irreflexive: p.irreflexive, + // 本体用 `utopia:temporal` 说了的照它说的(0031);没说的是 state + temporal: p.temporal.as_deref().unwrap_or("state").to_string(), }); pending_links.push((p.key.clone(), domains, ranges)); } @@ -719,6 +722,8 @@ pub async fn apply( symmetric: false, asymmetric: false, irreflexive: false, + // 属性建成 state,页面也不给别的选(0031 的「未决」里那一条) + temporal: "state".to_string(), }); pending_attr_domains.push((p.key.clone(), domain_ids)); } diff --git a/crates/utopia-server/src/rdf.rs b/crates/utopia-server/src/rdf.rs index 740b580a1..f921d4da9 100644 --- a/crates/utopia-server/src/rdf.rs +++ b/crates/utopia-server/src/rdf.rs @@ -398,10 +398,8 @@ pub fn emit_relation( } // 时间语义也照抄(0031):一个 event 谓词的事实两端是同一刻,一个 eternal 谓词的 // 事实没有日期——读的人不看这一条,会把前者读成一天的状态、后者读成从不知何时起。 - // 状态是默认,不写 - if r.temporal != "state" { - sink.l(&iri, &utopia("temporal"), &text(r.temporal.clone()))?; - } + // `state` 也写:导入把「没写」读成「不动」,不写它,改回 state 的关系重导入后还是 event + sink.l(&iri, &utopia("temporal"), &text(r.temporal.clone()))?; for d in &r.domains { if let Some(c) = vocab.class(*d) { let c = c.clone(); @@ -1600,6 +1598,18 @@ mod tests { )); } + /// 改回 state 的关系重导入也得是 state:导入把没写的 `utopia:temporal` 读成「不动」 + #[test] + fn a_state_relation_exports_its_temporal() { + let quads = export(Format::Turtle, |_, _, _| {}); + assert!(has( + &quads, + &format!("<{WORKS_FOR}>"), + "urn:utopia:ns:temporal", + "\"state\"" + )); + } + #[test] fn a_live_fact_is_both_a_statement_and_a_triple() { let quads = export(Format::Turtle, |sink, names, vocab| { diff --git a/crates/utopia-store/src/ontology.rs b/crates/utopia-store/src/ontology.rs index 32ff379a1..6ffe76820 100644 --- a/crates/utopia-store/src/ontology.rs +++ b/crates/utopia-store/src/ontology.rs @@ -273,6 +273,16 @@ pub fn lower_camel(label: &str) -> String { out } +/// `temporal` 只有三个值(0031):单条建、单条改、批量建、重导入更新都走这一句 +fn validate_temporal(temporal: &str) -> AppResult<()> { + if !matches!(temporal, "state" | "event" | "eternal") { + return Err(AppError::Validation( + "temporal must be state / event / eternal".into(), + )); + } + Ok(()) +} + fn validate_key(key: &str) -> AppResult<()> { let ok = !key.is_empty() && key.len() <= 40 @@ -608,11 +618,7 @@ pub async fn create_relation_type<'a>( unit: Option<&str>, ) -> AppResult { validate_key(key)?; - if !matches!(temporal, "state" | "event" | "eternal") { - return Err(AppError::Validation( - "temporal must be state / event / eternal".into(), - )); - } + validate_temporal(temporal)?; validate_attribute_fields(kind, domains, datatype)?; let mut tx = pool.begin().await?; let label = &lower_camel(label); @@ -826,11 +832,7 @@ pub async fn update_relation_type( } // 改名也归一:不然界面上改一次就能把小驼峰改回 "access to" let label = &lower_camel(label); - if !matches!(temporal, "state" | "event" | "eternal") { - return Err(AppError::Validation( - "temporal must be state / event / eternal".into(), - )); - } + validate_temporal(temporal)?; if datatype.is_some() && !matches!(datatype, Some("text" | "number" | "date" | "bool")) { return Err(AppError::Validation( "datatype must be text / number / date / bool".into(), @@ -1133,6 +1135,11 @@ pub async fn create_relation_with_iri( /// /// **key 不动**(它可能已被事实引用),**空描述不覆盖**(人写过的比上游的准), /// **domain/range 整体重写**——它们是上游声明的结构,不是人调过的措辞。 +/// **`temporal` 只在本体说了才写**(`Some`):没写 `utopia:temporal` 的本体不动它, +/// 人在页面上改过的就留着;写了的照本体,与 domain/range 同一个道理。 +/// 真改了就推 `updated_at`,与页面上改一样:0053 的短语绑定依据读它, +/// state 与非 state 决定短语怎么绑,旧值下做的决定要能被重审。 +#[allow(clippy::too_many_arguments)] pub async fn update_relation_from_import( pool: &PgPool, kb_id: Uuid, @@ -1141,11 +1148,17 @@ pub async fn update_relation_from_import( description: &str, domains: &[Uuid], ranges: &[Uuid], + temporal: Option<&str>, ) -> AppResult { + if let Some(t) = temporal { + validate_temporal(t)?; + } let row: Option<(Uuid,)> = sqlx::query_as( "UPDATE relation_types SET label = $3, - description = CASE WHEN $4 = '' THEN description ELSE $4 END + description = CASE WHEN $4 = '' THEN description ELSE $4 END, + temporal = COALESCE($5::text, temporal), + updated_at = CASE WHEN $5::text <> temporal THEN now() ELSE updated_at END WHERE kb_id = $1 AND iri = $2 RETURNING id", ) @@ -1153,6 +1166,7 @@ pub async fn update_relation_from_import( .bind(iri) .bind(label) .bind(description) + .bind(temporal) .fetch_optional(pool) .await?; let Some((id,)) = row else { @@ -1683,13 +1697,16 @@ pub struct BulkRelation { pub symmetric: bool, pub asymmetric: bool, pub irreflexive: bool, + /// `state` / `event` / `eternal`(0031)。OWL 自己没有这个概念,标准词表里的对象 + /// 属性全是 `state`;只有本体用 `utopia:temporal` 说了的才不是——导出端写的那一条 + pub temporal: String, } /// 一次插完一批关系或属性,返回 key → id。语义同 [`create_entity_types_bulk`]。 /// /// `kind` 决定走关系通道还是属性通道;`datatype` 只对属性有意义,关系传 `None`。 -/// `temporal` 固定 `state`——OWL 里没有对应概念,猜 event 或 eternal 都更差 -/// (与单条版的判断一致)。 +/// `temporal` 照每一行写:没有 `utopia:temporal` 注解的本体一律 `state` +///(与单条版的默认一致),写了的按它说的。 pub async fn create_relation_types_bulk( pool: &PgPool, kb_id: Uuid, @@ -1700,6 +1717,7 @@ pub async fn create_relation_types_bulk( } for r in rows { validate_key(&r.key)?; + validate_temporal(&r.temporal)?; } let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect(); // 导入来的词表自己就可能不一致(unece.org 里 `brandName` 与 @@ -1716,16 +1734,18 @@ pub async fn create_relation_types_bulk( let syms: Vec = rows.iter().map(|r| r.symmetric).collect(); let asyms: Vec = rows.iter().map(|r| r.asymmetric).collect(); let irrefs: Vec = rows.iter().map(|r| r.irreflexive).collect(); + let temporals: Vec<&str> = rows.iter().map(|r| r.temporal.as_str()).collect(); let out: Vec<(Uuid, String)> = sqlx::query_as( "INSERT INTO relation_types (id, kb_id, key, label, temporal, functional, inverse_functional, description, kind, datatype, iri, is_transitive, is_symmetric, is_asymmetric, is_irreflexive) - SELECT gen_random_uuid(), $1, k, l, 'state', fu, iv, d, kind, dt, i, + SELECT gen_random_uuid(), $1, k, l, tm, fu, iv, d, kind, dt, i, tr, sy, asym, irr FROM UNNEST($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[], - $8::bool[], $9::bool[], $10::bool[], $11::bool[], $12::bool[], $13::bool[]) - AS t(k, l, d, i, kind, dt, fu, iv, tr, sy, asym, irr) + $8::bool[], $9::bool[], $10::bool[], $11::bool[], $12::bool[], $13::bool[], + $14::text[]) + AS t(k, l, d, i, kind, dt, fu, iv, tr, sy, asym, irr, tm) ON CONFLICT (kb_id, key) DO NOTHING RETURNING id, key", ) @@ -1742,6 +1762,7 @@ pub async fn create_relation_types_bulk( .bind(&syms) .bind(&asyms) .bind(&irrefs) + .bind(&temporals) .fetch_all(pool) .await?; Ok(out.into_iter().map(|(id, k)| (k, id)).collect()) diff --git a/crates/utopia-store/tests/store/axioms_reach_the_database.rs b/crates/utopia-store/tests/store/axioms_reach_the_database.rs index 8fffd4a8f..15046544e 100644 --- a/crates/utopia-store/tests/store/axioms_reach_the_database.rs +++ b/crates/utopia-store/tests/store/axioms_reach_the_database.rs @@ -59,6 +59,7 @@ async fn every_axiom_survives_the_bulk_insert() -> anyhow::Result<()> { symmetric: s, asymmetric: a, irreflexive: r, + temporal: "state".into(), } }; // 一条全真、一条全假:全假那条守的是"没声明的不该被写成真" diff --git a/docs/decisions/0031-an-event-holds-at-the-moment-it-names.md b/docs/decisions/0031-an-event-holds-at-the-moment-it-names.md index 30e823fba..a23987066 100644 --- a/docs/decisions/0031-an-event-holds-at-the-moment-it-names.md +++ b/docs/decisions/0031-an-event-holds-at-the-moment-it-names.md @@ -27,6 +27,8 @@ The rule sits in `insert_fact_inner` and `correct_interval`, not in the writers. 2026-09-27: under a property declared as a state, the same rule now refuses a row whose two ends are equal, and a single date bound to a state is read by what its binding says it marks. See [0053](0053-a-phrase-decision-records-the-inputs-it-considered.md)'s revision of that date (#966). +2026-10-18: the export writes `utopia:temporal` on every property, `state` included, and the import reads it on an object property: a new relation takes the value the file declares, a re-import changes an existing one to it and moves its `updated_at` as an edit on the page does, and a file that says nothing leaves a relation alone, so a value a person set survives an unannotated re-import while re-importing an export is exact. A value other than the three is reported under the predicate in the import preview, and the relation stays as it was (#1291). + Both ends carry the value because that is the one shape the row can explain on its own. A reader that does not consult the predicate — an old client, an export consumer, a hand-written query — sees a fact that held on one day. It cannot see *holds from that day on*, which is what an open end would have told it. The representation fails safe. An undated observation of an event merges into an existing dated one, the mirror of the refinement path that already lets a dated observation supersede a bare row: "acquired in March" followed by "acquired" is one acquisition, not two. diff --git a/docs/design/ontology.md b/docs/design/ontology.md index 6f17dac79..340ea8be1 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -238,7 +238,9 @@ the prompt, a description is read by people and by the aligner. per signature costs distinct phrasings, not documents, and a changed ontology recomputes only the affected signatures instead of re-extracting [0044]. - **Standard vocabularies reify events as classes**, so every pack object property is a state; a - person changes `temporal` on the page and reads pick it up at once [0031]. + person changes `temporal` on the page and reads pick it up at once, and a vocabulary that + annotates a property with `utopia:temporal` (what the export writes) is imported with that value, + a re-import updating an existing relation's [0031]. ## Proposed and not built