Skip to content

Derived facts are not rewound by as_of in entity fact queries #549

Description

@ansonnmm

Summary

When querying an entity with a record-time as_of, asserted facts are correctly rewound to the requested belief state, but derived facts are still returned from the current state.

This can cause a historical belief query to contain derivations that did not exist yet at the requested as_of time.

Tested version

Tested against stock Utopia at:

b03a0f20a3d053323a3ec2669c7c9b4aeb9a8e14

No source, schema, or migration modifications were made.

Reproduction

  1. Create a knowledge base with entities and asserted facts that allow a rule/inference to produce a derived fact.
  2. Record the timestamps before those entities/facts are ingested.
  3. After ingestion and derivation complete, query the entity through MCP entity_facts using an as_of timestamp from before the entity/facts existed.
  4. Compare asserted and derived results.

Actual behavior

The asserted facts are correctly absent at the earlier as_of.

However, a derived fact created later is still returned.

In my reproduction, a query at a record-time before the relevant entity/facts had been ingested still returned a later derivation similar to:

Atlas · part of · Acme Systems [rule: transitive]

So the effective result is:

asserted state: rewound by as_of
derived state: current

Expected behavior

A record-time query should represent one internally consistent belief snapshot.

If a derived fact did not exist, or its premises were not present, at the requested as_of, that derived fact should not appear.

Conceptually:

belief_as_of(T)
=
asserted_facts_as_of(T)
+
derived_facts_as_of(T)

rather than:

belief_as_of(T)
=
asserted_facts_as_of(T)
+
derived_facts_now

This matters especially for historical decision review or any other use case that asks:

What did this knowledge base believe at time T?

Relevant code path

At the tested commit, the asserted query receives as_of, while the derived query appears not to:

pub async fn entity_facts(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult {
let id = args["entity_id"]
.as_str()
.and_then(|s| s.parse::<Uuid>().ok());
// 世界轴过滤在 SQL 里(world_axis,0022):没起点的事实从最早的证据起,结束了
// 不知哪天的到说出它的那份文档为止。这里只把 T 传下去,不自己解释 NULL
let at = args["at"].as_str().and_then(parse_when);
// 记录轴(0019 / #347):那一刻**我们持有**的事实。两根轴两个参数,绝不合成
// 一个——合起来就会拿「三月的世界,以今天的认知」去答「三月的世界,以三月的认知」
// 「更正到来之前」(#416):`before` 是 changes 里印出来的那个时刻,原样抄过来。
// 账本的钟是微秒,「严格早于 T」就是「不晚于 T 减一微秒」——这一步在这里做,
// 不让模型对着 ISO 字符串算小数秒的借位:算错一位,答的就是更正**之后**的状态,
// 而且看不出来(#351 那种错)。给了 before 就以它为准
let before = args["before"].as_str().and_then(parse_when);
let as_of = match before {
Some(t) => Some(just_before(t)),
None => args["as_of"].as_str().and_then(parse_when),
};
let Some(id) = id else {
return (
"Invalid entity_id (expected the uuid returned by find_entities).".to_string(),
json!({ "kind": "facts", "label": "?", "detail": "invalid id" }),
);
};
match utopia_store::graph::entity_detail(&ctx.state.pool, ctx.kb_id, id, at, as_of).await {
Ok((node, facts)) => {
// 规则的结论也是这个实体的一部分(0021)。**不给的话模型会拿那些
// 读数自己再判一遍**——而阈值写在规则里,它看不见,于是两处判断
// 迟早不一致,agent 那次还没有前提链、没有区间、也不进账本
let derived =
utopia_store::reasoning::derived_for_entity(&ctx.state.pool, ctx.kb_id, id, at)
.await
.unwrap_or_default();
let mut derived: Vec<String> = derived
.iter()
.map(|d| {
format!(
"{} · {} · {} [rule: {}]",
d.subject,
d.predicate,
d.object,
d.rule_name.as_deref().unwrap_or(&d.rule),
)
})
.collect();
let text = if facts.is_empty() && derived.is_empty() {
match at {
Some(t) => format!(
"{}: no facts valid as of {}.",
node.name,
crate::time_text::instant(t)
),
None => format!("{}: no recorded facts.", node.name),
}
} else {
let mut lines: Vec<String> = facts.iter().map(fact_line).collect();
lines.append(&mut derived);
lines.join("\n")
};
let detail = entity_facts_detail(facts.len(), at, as_of, before);
(
text,
json!({ "kind": "facts", "label": node.name, "detail": detail }),

The same semantic question may also apply to derived proof expansion: if a historical derived fact/proof is exposed, its premises and evidence should correspond to the same record-time snapshot.

Suggested acceptance criteria

I am not prescribing a particular implementation, but a regression test could establish the invariant:

  1. Create asserted facts at record-time T2.
  2. Materialize a derivation after T2.
  3. Query with as_of=T1, where T1 < T2.
  4. Neither the later asserted facts nor derivations based on them are returned.
  5. Query with current as_of and both are returned.
  6. If proof retrieval supports historical queries, the proof and its premises obey the same record-time semantics.

Why I think this is a correctness issue rather than a feature request

Utopia already exposes at and as_of as distinct world-time and record-time semantics. The unexpected behavior is that one category of graph state obeys the record-time axis while another category in the same response does not.

If this behavior is intentional, clarification of the intended semantics would also be useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions