From 14091d21fb0887f5b33a39a0f06f90e73a55855a Mon Sep 17 00:00:00 2001 From: cxzg007 <108442142+cxzg007@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:24:47 +0800 Subject: [PATCH] fix(kg): compute real relationship duration for temporal stability metric (#1143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 江俊杰 --- CHANGELOG.md | 5 +++ semantica/kg/temporal_query.py | 12 +++++-- tests/kg/test_kg.py | 63 ++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac9df42..481a64af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/semantica/kg/temporal_query.py b/semantica/kg/temporal_query.py index f5bc227a..c335c95c 100644 --- a/semantica/kg/temporal_query.py +++ b/semantica/kg/temporal_query.py @@ -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 diff --git a/tests/kg/test_kg.py b/tests/kg/test_kg.py index a983694d..4d02b1e4 100644 --- a/tests/kg/test_kg.py +++ b/tests/kg/test_kg.py @@ -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": [