Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 10 additions & 25 deletions crates/utopia-ingest/src/ontology_rdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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` 都没有,
Expand Down Expand Up @@ -356,7 +353,6 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
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")
Expand All @@ -378,6 +374,12 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
};
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());
Expand Down Expand Up @@ -457,19 +459,8 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
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;
Expand Down Expand Up @@ -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));
}
}

Expand Down
46 changes: 36 additions & 10 deletions crates/utopia-server/src/api/token_setup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,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 {
Expand All @@ -487,14 +487,14 @@ async fn an_imported_property_keeps_the_temporal_it_declares() -> anyhow::Result
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
@prefix utopia: <urn:utopia:ns:> .\n\
@prefix ex: <http://example.org/> .\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<chrono::Utc>)>(
"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
}
Expand All @@ -505,7 +505,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!(
Expand All @@ -516,21 +517,46 @@ 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"
);

// 第三版把注解去掉:没说就不动,人在页面上改过的也不会被重导入抹掉
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(())
}
Expand Down
18 changes: 14 additions & 4 deletions crates/utopia-server/src/rdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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| {
Expand Down
5 changes: 4 additions & 1 deletion crates/utopia-store/src/ontology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
)
Expand Down
4 changes: 2 additions & 2 deletions docs/decisions/0031-an-event-holds-at-the-moment-it-names.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Loading