knowledge: valid-time on facts + interval-aware conflict detection
Adds bitemporal support to entity_facts. Existing `created_at` is
transaction time (when we recorded the fact); the new
`valid_from` / `valid_until` BIGINT columns are valid time (when the
fact is/was true in the real world). NULL on either side = unbounded
on that side, both NULL = "always-true / unknown" — matches the
default state of every legacy row, no backfill needed.
The split matters for time-bounded predicates like
is_in_relationship_with / lives_in / works_at: recording the fact
once doesn't mean the relationship is still ongoing. Same predicate
across different windows ("lives_in NYC 2018-2020", "lives_in SF
2020-present") is no longer a conflict — the interval-aware check
in get_entity only flags pairs whose windows overlap. Facts with no
valid-time data still flag against everything (worst case for legacy
rows — user adds dates to suppress).
API surface:
- POST /knowledge/facts accepts optional valid_from / valid_until.
- PATCH /knowledge/facts/{id} accepts both with tri-state semantics:
field omitted = leave alone, JSON null = clear to NULL, number =
set. Implemented via a small serde helper around Option<Option>.
- GET /knowledge/entities/{id} surfaces both fields per fact and
uses them in conflict detection.
Agent path (insight_generator) writes NULL/NULL for now — deriving
valid_from from the source photo's date_taken is slated for a
follow-up agent tool alongside Phase 2's supersession.
Test pins set + clear semantics via update_fact: setting both
bounds, leaving them alone on a subsequent patch, then clearing
valid_until back to NULL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,15 @@ pub struct FactPatch {
|
||||
pub object_value: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub confidence: Option<f32>,
|
||||
/// Real-world valid-time bounds. Outer Some = "patch this column";
|
||||
/// inner Some(val) = set to that unix-seconds value; inner None =
|
||||
/// clear back to NULL ("unbounded"). The double-Option lets the
|
||||
/// HTTP layer distinguish "field omitted" (leave alone) from
|
||||
/// "field sent as null" (clear) — needed for these specifically
|
||||
/// because there's no sentinel string-empty equivalent like the
|
||||
/// other fields have.
|
||||
pub valid_from: Option<Option<i64>>,
|
||||
pub valid_until: Option<Option<i64>>,
|
||||
}
|
||||
|
||||
pub struct RecentActivity {
|
||||
@@ -1074,6 +1083,18 @@ impl KnowledgeDao for SqliteKnowledgeDao {
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
|
||||
}
|
||||
if let Some(new_from) = patch.valid_from {
|
||||
diesel::update(entity_facts.filter(id.eq(fact_id)))
|
||||
.set(valid_from.eq(new_from))
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
|
||||
}
|
||||
if let Some(new_until) = patch.valid_until {
|
||||
diesel::update(entity_facts.filter(id.eq(fact_id)))
|
||||
.set(valid_until.eq(new_until))
|
||||
.execute(conn.deref_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
|
||||
}
|
||||
|
||||
entity_facts
|
||||
.filter(id.eq(fact_id))
|
||||
@@ -1347,6 +1368,8 @@ mod tests {
|
||||
created_at: 0,
|
||||
persona_id: persona_id.to_string(),
|
||||
user_id,
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1565,6 +1588,8 @@ mod tests {
|
||||
created_at: 0,
|
||||
persona_id: "ghost".to_string(),
|
||||
user_id: alice,
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
@@ -1573,6 +1598,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_fact_can_set_and_clear_valid_time() {
|
||||
// FactPatch.valid_from / valid_until are Option<Option<i64>>
|
||||
// so PATCH can distinguish "leave alone" (None) from "set to
|
||||
// value" (Some(Some(n))) and "clear back to NULL" (Some(None)).
|
||||
let cx = opentelemetry::Context::new();
|
||||
let conn = connection_with_fks_on();
|
||||
let alice = create_user(&conn, "alice");
|
||||
create_persona_row(&conn, alice, "default");
|
||||
|
||||
let mut dao = SqliteKnowledgeDao::from_connection(conn.clone());
|
||||
let cameron = make_entity(&mut dao, "Cameron");
|
||||
let fact = add_fact(
|
||||
&mut dao,
|
||||
cameron.id,
|
||||
"is_in_relationship_with",
|
||||
"Alex",
|
||||
alice,
|
||||
"default",
|
||||
);
|
||||
assert_eq!(fact.valid_from, None);
|
||||
assert_eq!(fact.valid_until, None);
|
||||
|
||||
// Set both bounds.
|
||||
let updated = dao
|
||||
.update_fact(
|
||||
&cx,
|
||||
fact.id,
|
||||
FactPatch {
|
||||
predicate: None,
|
||||
object_value: None,
|
||||
status: None,
|
||||
confidence: None,
|
||||
valid_from: Some(Some(1577836800)), // 2020-01-01
|
||||
valid_until: Some(Some(1640995200)), // 2022-01-01
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.valid_from, Some(1577836800));
|
||||
assert_eq!(updated.valid_until, Some(1640995200));
|
||||
|
||||
// Leave alone: omit both — values persist.
|
||||
let still = dao
|
||||
.update_fact(
|
||||
&cx,
|
||||
fact.id,
|
||||
FactPatch {
|
||||
predicate: None,
|
||||
object_value: None,
|
||||
status: None,
|
||||
confidence: None,
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(still.valid_from, Some(1577836800));
|
||||
assert_eq!(still.valid_until, Some(1640995200));
|
||||
|
||||
// Clear valid_until back to NULL (relationship ongoing again).
|
||||
let cleared = dao
|
||||
.update_fact(
|
||||
&cx,
|
||||
fact.id,
|
||||
FactPatch {
|
||||
predicate: None,
|
||||
object_value: None,
|
||||
status: None,
|
||||
confidence: None,
|
||||
valid_from: None,
|
||||
valid_until: Some(None),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cleared.valid_from, Some(1577836800));
|
||||
assert_eq!(cleared.valid_until, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_entity_clears_relational_facts_that_would_violate_check() {
|
||||
// entity_facts has a CHECK that at least one of object_entity_id /
|
||||
@@ -1607,6 +1713,8 @@ mod tests {
|
||||
created_at: 0,
|
||||
persona_id: "default".to_string(),
|
||||
user_id: alice,
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user