fix(kg): compute real relationship duration for temporal stability metric (#1143)

analyze_evolution() previously appended a constant placeholder (durations.append(1)) for every bounded relationship, so the stability metric was always 1.0 when any bounded relationship existed and 0 otherwise, never reflecting actual valid-time durations.

Stability now computes the mean valid-time duration in seconds ((valid_until - valid_from).total_seconds()) across relationships with both bounds set; unbounded/half-open intervals are skipped and non-positive intervals clamped to 0. Adds unit tests and a CHANGELOG entry.

Co-authored-by: 江俊杰 <jiangjunjie.37@jd.com>
This commit is contained in:
cxzg007
2026-08-22 14:24:47 +05:00
committed by GitHub
co-authored by 江俊杰
parent 58125a0a93
commit 14091d21fb
3 changed files with 77 additions and 3 deletions
+5
View File
@@ -108,6 +108,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **The temporal-evolution `stability` metric was a hardcoded placeholder, not a duration**
- `TemporalGraphQuery.analyze_evolution()` documents `stability` as a "relationship duration/stability measure", but the implementation appended a constant `1` for every relationship with both `valid_from` and `valid_until` set (`durations.append(1) # Placeholder`). The reported stability was therefore always `1.0` when any bounded relationship existed and `0` otherwise — it never reflected how long relationships actually stayed valid, so it could not distinguish a graph of decade-long relationships from one of one-second relationships
- `stability` now computes the mean valid-time duration in seconds (`(valid_until - valid_from).total_seconds()`) across relationships that have both bounds set. Relationships with a missing or open `valid_from`/`valid_until` are skipped (their duration is unbounded), and non-positive intervals are clamped to `0`; an empty set still reports `0`
- New tests in `tests/kg/test_kg.py` assert the mean-duration result, the skipping of unbounded/half-open intervals, and the empty-graph zero case
- **Every timestamp an export or a provenance record wrote was timezone-naive** (closes #1114) by @fabio-rovai
- `semantica/export/` stamped with `datetime.now().isoformat()`, which reads the machine's **local** clock; `semantica/provenance/` stamped with `datetime.utcnow().isoformat()`, which reads **UTC**. Both produce a naive value and both serialize identically, so nothing downstream can tell which zone a given timestamp belongs to — the same string means two different instants depending on which module wrote it
- In RDF the consequence is silent rather than loud. Under XSD 1.1 a value with no timezone compared against one with a timezone is indeterminate whenever the two fall inside the ±14 hour window; SPARQL turns an indeterminate comparison into an error, and `FILTER` discards errors as non-matches. A timezone-qualified query over an Oxigraph store returns an answer with every Semantica-written record quietly absent from it, which is a poor property for `prov:generatedAtTime`, `prov:startedAtTime`, `prov:endedAtTime` and `prov:atTime` to have
+9 -3
View File
@@ -510,6 +510,9 @@ class TemporalGraphQuery:
- "count": Number of relationships
- "diversity": Number of unique relationship types
- "stability": Relationship duration/stability measure
(mean valid-time duration in seconds across
relationships that have both ``valid_from`` and
``valid_until`` set)
**options: Additional analysis options (unused)
Returns:
@@ -582,14 +585,17 @@ class TemporalGraphQuery:
result["diversity"] = len(rel_types)
if "stability" in metrics:
# Calculate stability based on relationship duration
# Stability is the mean duration (in seconds) that relationships
# remain valid. Relationships without a bounded validity interval
# (missing/open ``valid_from`` or ``valid_until``) are skipped, and
# non-positive intervals are clamped to zero.
durations = []
for rel in relationships:
valid_from = self._parse_time(rel.get("valid_from"))
valid_until = self._parse_time(rel.get("valid_until"))
if valid_from and valid_until:
# Simplified duration calculation
durations.append(1) # Placeholder
duration_seconds = (valid_until - valid_from).total_seconds()
durations.append(max(0.0, duration_seconds))
result["stability"] = sum(durations) / len(durations) if durations else 0
return result
+63
View File
@@ -407,6 +407,69 @@ class TestTemporalGraphQuery(unittest.TestCase):
self.assertEqual(result["num_relationships"], 1)
def test_analyze_evolution_stability_is_mean_duration_seconds(self):
day = 86400.0
graph = {
"relationships": [
{
"source": "1",
"target": "2",
"type": "a",
"valid_from": "2024-01-01",
"valid_until": "2024-01-02", # 1 day
},
{
"source": "2",
"target": "3",
"type": "b",
"valid_from": "2024-01-01",
"valid_until": "2024-01-04", # 3 days
},
]
}
result = self.query_engine.analyze_evolution(graph, metrics=["stability"])
# Mean of 1-day and 3-day durations == 2 days in seconds.
self.assertAlmostEqual(result["stability"], 2 * day)
def test_analyze_evolution_stability_skips_unbounded_intervals(self):
graph = {
"relationships": [
{
"source": "1",
"target": "2",
"type": "bounded",
"valid_from": "2024-01-01",
"valid_until": "2024-01-02",
},
{
"source": "2",
"target": "3",
"type": "open",
"valid_from": "2024-01-01",
"valid_until": TemporalBound.OPEN,
},
{
"source": "3",
"target": "4",
"type": "no-start",
"valid_until": "2024-06-01",
},
]
}
result = self.query_engine.analyze_evolution(graph, metrics=["stability"])
# Only the fully bounded relationship contributes (1 day).
self.assertAlmostEqual(result["stability"], 86400.0)
def test_analyze_evolution_stability_empty_is_zero(self):
result = self.query_engine.analyze_evolution(
{"relationships": []}, metrics=["stability"]
)
self.assertEqual(result["stability"], 0)
def test_query_at_time_legacy_transaction_axis_uses_valid_from_when_recorded_missing(self):
graph = {
"relationships": [