From efda84cbd6344cd101b9e47555744fcc6960b510 Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Fri, 16 Oct 2026 21:01:50 +0200 Subject: [PATCH 1/2] The importer reads utopia:temporal on a property The export has written utopia:temporal on every property that is not a state since 0031, and the importer never read it back: an ontology that said a relation is an event came in as a state, and the rule of #1242, which binds a pushed phrase by the declaration only when the relation is not a state, could never apply to an imported vocabulary. The turtle projection reads urn:utopia:ns:temporal with one of state, event or eternal; any other value is reported under the preview's unprojected predicates and the property stays unset. A new relation is created with the value, a re-import updates an existing relation's temporal to what the file says, and a file that says nothing leaves it alone, so a value a person set on the page survives an unannotated re-import. Attributes stay state. Signed-off-by: Bogdan Szabo --- crates/utopia-ingest/src/ontology_rdf.rs | 69 +++++++++++++++++++ .../src/api/token_setup_tests.rs | 68 ++++++++++++++++++ crates/utopia-server/src/owl_import.rs | 5 ++ crates/utopia-store/src/ontology.rs | 50 +++++++++----- .../tests/store/axioms_reach_the_database.rs | 1 + ...1-an-event-holds-at-the-moment-it-names.md | 2 + docs/design/ontology.md | 4 +- 7 files changed, 182 insertions(+), 17 deletions(-) diff --git a/crates/utopia-ingest/src/ontology_rdf.rs b/crates/utopia-ingest/src/ontology_rdf.rs index 00090a0cf..310cb9c58 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,11 @@ 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:"; +/// `utopia:temporal` 写了 `state`/`event`/`eternal` 以外的值时记在 `unprojected` 下的键 +pub const UNPROJECTED_TEMPORAL: &str = + "utopia:temporal with a value other than state/event/eternal"; /// schema.org 自己那套 domain/range。**不是标准词汇**,所以写死在这里, /// 但值得认:schema.org 及派生词汇表一个 `rdfs:domain` 都没有, @@ -342,8 +351,12 @@ 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 == utopia_temporal || p == format!("{RDFS}label") || p == format!("{RDFS}comment") || p == format!("{RDFS}subClassOf") @@ -444,6 +457,19 @@ 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 p == utopia_temporal { + // 导出端写的就是这三个字面值(0031)。别的值不猜:报告出来,落库按 `state` + match t.object_lit.as_ref().map(|(v, _)| v.as_str()) { + Some(v @ ("state" | "event" | "eternal")) => { + temporal.entry(t.subject.clone()).or_insert(v.to_string()); + } + _ => { + *proj + .unprojected + .entry(UNPROJECTED_TEMPORAL.to_string()) + .or_insert(0) += 1; + } + } } else if !known(p) { // 报告而不是丢弃:预览页要能说清"这个文件里还有什么我们没消费" *proj.unprojected.entry(p.to_string()).or_insert(0) += 1; @@ -582,6 +608,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 +1743,48 @@ 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" + ); + // 三个值以外的不猜:留空并报告出来,预览页能说清这一条没进来 + assert_eq!(by("met").temporal, None); + assert_eq!(p.unprojected.get(UNPROJECTED_TEMPORAL), Some(&1)); + assert!( + !p.unprojected + .keys() + .any(|k| k.contains("urn:utopia:ns:temporal")), + "认了就不该再按谓词算进「暂未投影」" + ); + } } #[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 c1b8da788..cf0696986 100644 --- a/crates/utopia-server/src/api/token_setup_tests.rs +++ b/crates/utopia-server/src/api/token_setup_tests.rs @@ -443,3 +443,71 @@ async fn importing_an_ontology_queues_alignment_behind_the_index_refresh() -> an f.cleanup().await?; result } + +/// 导入读 `utopia:temporal`(0031)。导出端一直写这一条,导入端从前不读:一份本体 +/// 说「这条关系是一个时刻」,导进来照样 `state`,#1242 的「非 state 的关系不经模型 +/// 就能绑」对导入的本体永远不成立。新建的照本体建;重导入时本体说了的改过来, +/// 没说的不动——与 domain/range 同一个道理 +#[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 temporal_of = |iri: &'static str| { + let pool = f.pool.clone(); + async move { + sqlx::query_scalar::<_, String>( + "SELECT temporal FROM relation_types WHERE kb_id = $1 AND iri = $2", + ) + .bind(kb) + .bind(iri) + .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}"); + anyhow::ensure!(temporal_of("http://example.org/worksAt").await? == "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}"); + anyhow::ensure!( + temporal_of("http://example.org/worksAt").await? == "eternal", + "a re-import writes the temporal the file now declares" + ); + anyhow::ensure!( + temporal_of("http://example.org/attended").await? == "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!( + temporal_of("http://example.org/worksAt").await? == "eternal", + "an unannotated re-import leaves temporal alone" + ); + 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-store/src/ontology.rs b/crates/utopia-store/src/ontology.rs index 32ff379a1..fe84c30f3 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,9 @@ pub async fn create_relation_with_iri( /// /// **key 不动**(它可能已被事实引用),**空描述不覆盖**(人写过的比上游的准), /// **domain/range 整体重写**——它们是上游声明的结构,不是人调过的措辞。 +/// **`temporal` 只在本体说了才写**(`Some`):没写 `utopia:temporal` 的本体不动它, +/// 人在页面上改过的就留着;写了的照本体,与 domain/range 同一个道理。 +#[allow(clippy::too_many_arguments)] pub async fn update_relation_from_import( pool: &PgPool, kb_id: Uuid, @@ -1141,11 +1146,16 @@ 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) WHERE kb_id = $1 AND iri = $2 RETURNING id", ) @@ -1153,6 +1163,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 +1694,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 +1714,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 +1731,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 +1759,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..eda0f2be2 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 @@ -71,3 +71,5 @@ An event row from before this record has a start and an open end. `facts_holds_t The status line as it stood on 2026-09-27, before status lines were cut to one line: > implemented (#486) · `Validity::under` normalises every write by the predicate's `temporal` — in `insert_fact_inner`, so extraction, the nod and a person's own fact all pass through it, and in `correct_interval` · `world_axis` reads an event as the bucket it names and an eternal fact as open at both ends, and the evaluator's `read_span` says the same · the prompt marks `[event]` and `[eternal]` relations and tells the model what to write, for a base that has any · the export carries `utopia:temporal` on a property that is not a state · no schema change, and a row written before this reads correctly · the panel still prints an event as `T ~ T` and the ontology page still does not say what the three values do — that is the UI cut · 2026-09-10: corpus-grown relations now take `temporal` from the proposal instead of `state` for all (#593, on [0007](0007-who-decides-what-becomes-a-relation.md)) · the five packs were measured for the same gap and there is none to fill: of 946 schema.org object properties, 60 in PROV-O, 32 in ORG and 73 in IOF-core, **no object property is an event** — standard vocabularies reify an event as a class (`PublicationEvent`, `prov:Generation`) and their object properties are states pointing at it, so `create_relation_types_bulk` writing `state` is right by construction, not by omission; IOF's `…AtAllTimes` family is the one `eternal` candidate and is left as `state` until a base needs it; a person changes any of them on the ontology page and the read side picks it up at once, since `world_axis` looks the predicate's `temporal` up at read time + +> 2026-10-16: the importer reads `utopia:temporal` on an object property, the annotation the export had written since this record and the import had never read back. A property annotated `event` or `eternal` is created with that value; a re-import of a vocabulary that now carries the annotation changes an existing relation's `temporal` to what the file says, and a file that says nothing leaves it alone, so a value a person set on the page survives an unannotated re-import. Any other value is reported under the import preview's unprojected predicates and the relation stays `state`. Attributes are still created as `state`. 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 From 7e5b556e2753c7c44987ec3c804cf180e9a0bf0e Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Sun, 18 Oct 2026 08:13:56 +0200 Subject: [PATCH 2/2] The import reports a bad utopia:temporal under its IRI, the export writes state too, and an import that changes temporal moves updated_at Signed-off-by: Bogdan Szabo --- crates/utopia-ingest/src/ontology_rdf.rs | 35 ++++---------- .../src/api/token_setup_tests.rs | 46 +++++++++++++++---- crates/utopia-server/src/rdf.rs | 18 ++++++-- crates/utopia-store/src/ontology.rs | 5 +- ...1-an-event-holds-at-the-moment-it-names.md | 4 +- 5 files changed, 66 insertions(+), 42 deletions(-) diff --git a/crates/utopia-ingest/src/ontology_rdf.rs b/crates/utopia-ingest/src/ontology_rdf.rs index 310cb9c58..824e5a0d0 100644 --- a/crates/utopia-ingest/src/ontology_rdf.rs +++ b/crates/utopia-ingest/src/ontology_rdf.rs @@ -91,9 +91,6 @@ 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:"; -/// `utopia:temporal` 写了 `state`/`event`/`eternal` 以外的值时记在 `unprojected` 下的键 -pub const UNPROJECTED_TEMPORAL: &str = - "utopia:temporal with a value other than state/event/eternal"; /// schema.org 自己那套 domain/range。**不是标准词汇**,所以写死在这里, /// 但值得认:schema.org 及派生词汇表一个 `rdfs:domain` 都没有, @@ -356,7 +353,6 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result let utopia_temporal = format!("{UTOPIA_NS}temporal"); let known = |p: &str| { p == RDF_TYPE - || p == utopia_temporal || p == format!("{RDFS}label") || p == format!("{RDFS}comment") || p == format!("{RDFS}subClassOf") @@ -378,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()); @@ -457,19 +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 p == utopia_temporal { - // 导出端写的就是这三个字面值(0031)。别的值不猜:报告出来,落库按 `state` - match t.object_lit.as_ref().map(|(v, _)| v.as_str()) { - Some(v @ ("state" | "event" | "eternal")) => { - temporal.entry(t.subject.clone()).or_insert(v.to_string()); - } - _ => { - *proj - .unprojected - .entry(UNPROJECTED_TEMPORAL.to_string()) - .or_insert(0) += 1; - } - } + } 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; @@ -1775,15 +1766,9 @@ ex:met a owl:ObjectProperty ; rdfs:label "met" ; utopia:temporal "sometimes" . None, "没写就是没写,落库再按 state" ); - // 三个值以外的不猜:留空并报告出来,预览页能说清这一条没进来 + // 三个值以外的不猜:留空并按谓词报告出来,只有 `met` 那一条,预览页能说清它没进来 assert_eq!(by("met").temporal, None); - assert_eq!(p.unprojected.get(UNPROJECTED_TEMPORAL), Some(&1)); - assert!( - !p.unprojected - .keys() - .any(|k| k.contains("urn:utopia:ns:temporal")), - "认了就不该再按谓词算进「暂未投影」" - ); + assert_eq!(p.unprojected.get("urn:utopia:ns:temporal"), Some(&1)); } } diff --git a/crates/utopia-server/src/api/token_setup_tests.rs b/crates/utopia-server/src/api/token_setup_tests.rs index cf0696986..176cb8cf5 100644 --- a/crates/utopia-server/src/api/token_setup_tests.rs +++ b/crates/utopia-server/src/api/token_setup_tests.rs @@ -447,7 +447,7 @@ async fn importing_an_ontology_queues_alignment_behind_the_index_refresh() -> an /// 导入读 `utopia:temporal`(0031)。导出端一直写这一条,导入端从前不读:一份本体 /// 说「这条关系是一个时刻」,导进来照样 `state`,#1242 的「非 state 的关系不经模型 /// 就能绑」对导入的本体永远不成立。新建的照本体建;重导入时本体说了的改过来, -/// 没说的不动——与 domain/range 同一个道理 +/// 没说的不动——与 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 { @@ -460,14 +460,14 @@ async fn an_imported_property_keeps_the_temporal_it_declares() -> anyhow::Result @prefix rdfs: .\n\ @prefix utopia: .\n\ @prefix ex: .\n"; - let temporal_of = |iri: &'static str| { + let works_at = || { let pool = f.pool.clone(); async move { - sqlx::query_scalar::<_, String>( - "SELECT temporal FROM relation_types WHERE kb_id = $1 AND iri = $2", + 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) - .bind(iri) .fetch_one(&pool) .await } @@ -478,7 +478,8 @@ async fn an_imported_property_keeps_the_temporal_it_declares() -> anyhow::Result 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}"); - anyhow::ensure!(temporal_of("http://example.org/worksAt").await? == "state"); + let (temporal, created) = works_at().await?; + anyhow::ensure!(temporal == "state"); // 第二版:worksAt 加了注解,attended 是带注解新建的 let second = format!( @@ -489,12 +490,24 @@ async fn an_imported_property_keeps_the_temporal_it_declares() -> anyhow::Result ); 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_of("http://example.org/worksAt").await? == "eternal", + temporal == "eternal", "a re-import writes the temporal the file now declares" ); anyhow::ensure!( - temporal_of("http://example.org/attended").await? == "event", + 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" ); @@ -502,8 +515,21 @@ async fn an_imported_property_keeps_the_temporal_it_declares() -> anyhow::Result let (status, body) = f.import_turtle(kb, &token, &first).await?; anyhow::ensure!(status == StatusCode::OK, "{status} {body}"); anyhow::ensure!( - temporal_of("http://example.org/worksAt").await? == "eternal", - "an unannotated re-import leaves temporal alone" + 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(()) } 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 fe84c30f3..6ffe76820 100644 --- a/crates/utopia-store/src/ontology.rs +++ b/crates/utopia-store/src/ontology.rs @@ -1137,6 +1137,8 @@ pub async fn create_relation_with_iri( /// **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, @@ -1155,7 +1157,8 @@ pub async fn update_relation_from_import( "UPDATE relation_types SET label = $3, description = CASE WHEN $4 = '' THEN description ELSE $4 END, - temporal = COALESCE($5::text, temporal) + 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", ) 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 eda0f2be2..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. @@ -71,5 +73,3 @@ An event row from before this record has a start and an open end. `facts_holds_t The status line as it stood on 2026-09-27, before status lines were cut to one line: > implemented (#486) · `Validity::under` normalises every write by the predicate's `temporal` — in `insert_fact_inner`, so extraction, the nod and a person's own fact all pass through it, and in `correct_interval` · `world_axis` reads an event as the bucket it names and an eternal fact as open at both ends, and the evaluator's `read_span` says the same · the prompt marks `[event]` and `[eternal]` relations and tells the model what to write, for a base that has any · the export carries `utopia:temporal` on a property that is not a state · no schema change, and a row written before this reads correctly · the panel still prints an event as `T ~ T` and the ontology page still does not say what the three values do — that is the UI cut · 2026-09-10: corpus-grown relations now take `temporal` from the proposal instead of `state` for all (#593, on [0007](0007-who-decides-what-becomes-a-relation.md)) · the five packs were measured for the same gap and there is none to fill: of 946 schema.org object properties, 60 in PROV-O, 32 in ORG and 73 in IOF-core, **no object property is an event** — standard vocabularies reify an event as a class (`PublicationEvent`, `prov:Generation`) and their object properties are states pointing at it, so `create_relation_types_bulk` writing `state` is right by construction, not by omission; IOF's `…AtAllTimes` family is the one `eternal` candidate and is left as `state` until a base needs it; a person changes any of them on the ontology page and the read side picks it up at once, since `world_axis` looks the predicate's `temporal` up at read time - -> 2026-10-16: the importer reads `utopia:temporal` on an object property, the annotation the export had written since this record and the import had never read back. A property annotated `event` or `eternal` is created with that value; a re-import of a vocabulary that now carries the annotation changes an existing relation's `temporal` to what the file says, and a file that says nothing leaves it alone, so a value a person set on the page survives an unannotated re-import. Any other value is reported under the import preview's unprojected predicates and the relation stays `state`. Attributes are still created as `state`.