mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-15 04:00:33 +00:00
feat(export): add opt-in metric_errors column to DistanceExporter (#960)
* feat(export): add opt-in metric_errors column to DistanceExporter
Add a 'metric_errors' field to compute_pairs() output that lets
downstream consumers programmatically distinguish legitimate 'no path'
(None) from computation failures (None + error name).
Usage:
rows = exporter.compute_pairs(include=[..., 'metric_errors'])
# row['metric_errors'] == '' → all metrics succeeded
# row['metric_errors'] == 'hop_count,weighted_distance' → those failed
Design decisions:
- Opt-in: column only appears when explicitly requested via include=
- Default export schema unchanged (backward compatible)
- Comma-separated metric names (not exception messages) — stable for
programmatic filtering without exposing internal error details
- Helpers now return (value, error_name | None) tuples internally
Follow-up to #879, as discussed in its review thread.
* fix: address Qodo findings — track betweenness errors and remove unused constant
1. _betweenness() now returns (dict, error) tuple like the other helpers,
so betweenness computation failures appear in metric_errors.
2. Removed unused _ERROR_COLUMNS constant (dead code).
All 77 tests in tests/export/ pass.
* docs(changelog): add entry for opt-in metric_errors column (#960)
---------
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
This commit is contained in:
co-authored by
Mohd Kaif
KaifAhmad1
parent
0fa3483b96
commit
2cfb5de43d
@@ -57,28 +57,36 @@ def exporter():
|
||||
def test_hop_distance_logs_warning_on_exception(exporter, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"):
|
||||
result = exporter._hop_distance({}, "a", "b")
|
||||
assert result is None
|
||||
value, error = result
|
||||
assert value is None
|
||||
assert error == "hop_count"
|
||||
assert any("Hop distance" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_weighted_distance_logs_warning_on_exception(exporter, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"):
|
||||
result = exporter._weighted_distance({}, "a", "b")
|
||||
assert result is None
|
||||
value, error = result
|
||||
assert value is None
|
||||
assert error == "weighted_distance"
|
||||
assert any("Weighted distance" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_semantic_similarity_logs_warning_on_exception(exporter, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"):
|
||||
result = exporter._semantic_similarity({}, "a", "b")
|
||||
assert result is None
|
||||
value, error = result
|
||||
assert value is None
|
||||
assert error == "semantic_similarity"
|
||||
assert any("Semantic similarity" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_betweenness_logs_warning_on_exception(exporter, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"):
|
||||
result = exporter._betweenness({})
|
||||
assert result == {}
|
||||
value, error = result
|
||||
assert value == {}
|
||||
assert error == "betweenness"
|
||||
assert any("Betweenness" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
@@ -103,5 +111,7 @@ def test_hop_distance_no_warning_when_kg_unavailable(caplog):
|
||||
exp._path_finder = None
|
||||
with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"):
|
||||
result = exp._hop_distance({}, "a", "b")
|
||||
assert result is None
|
||||
value, error = result
|
||||
assert value is None
|
||||
assert error is None
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Tests for DistanceExporter metric_errors column.
|
||||
|
||||
Verifies that when ``include=["metric_errors"]`` is passed to
|
||||
``compute_pairs()``, the exported rows contain a ``metric_errors`` field
|
||||
that distinguishes computation failures from legitimate None results.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantica.export.distance_exporter import DistanceExporter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graph():
|
||||
"""Minimal graph mock with two nodes."""
|
||||
graph = MagicMock()
|
||||
node_a = MagicMock(node_id="a", node_type="entity", content="A", properties={})
|
||||
node_b = MagicMock(node_id="b", node_type="entity", content="B", properties={})
|
||||
graph.nodes = {"a": node_a, "b": node_b}
|
||||
graph.edges = []
|
||||
return graph
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exporter(mock_graph):
|
||||
"""DistanceExporter with mocked KG components."""
|
||||
exp = DistanceExporter(mock_graph)
|
||||
exp._path_finder = MagicMock()
|
||||
exp._similarity = MagicMock()
|
||||
exp._centrality = MagicMock()
|
||||
return exp
|
||||
|
||||
|
||||
class TestMetricErrorsColumn:
|
||||
"""Tests for the opt-in metric_errors export column."""
|
||||
|
||||
def test_metric_errors_empty_on_success(self, exporter):
|
||||
"""When all metrics succeed, metric_errors is an empty string."""
|
||||
exporter._path_finder.bfs_shortest_path.return_value = {"path": ["a", "x", "b"]}
|
||||
exporter._path_finder.dijkstra_shortest_path.return_value = {"total_weight": 2.5, "path": ["a", "b"]}
|
||||
exporter._similarity.cosine_similarity.return_value = 0.87
|
||||
|
||||
rows = exporter.compute_pairs(include=["hop_count", "weighted_distance", "semantic_similarity", "metric_errors"])
|
||||
|
||||
assert len(rows) == 2 # a->b and b->a
|
||||
for row in rows:
|
||||
assert "metric_errors" in row
|
||||
assert row["metric_errors"] == ""
|
||||
|
||||
def test_metric_errors_records_single_failure(self, exporter):
|
||||
"""When one metric fails, its name appears in metric_errors."""
|
||||
exporter._path_finder.bfs_shortest_path.return_value = {"path": ["a", "b"]}
|
||||
exporter._path_finder.dijkstra_shortest_path.side_effect = RuntimeError("negative cycle")
|
||||
exporter._similarity.cosine_similarity.return_value = 0.5
|
||||
|
||||
rows = exporter.compute_pairs(include=["hop_count", "weighted_distance", "semantic_similarity", "metric_errors"])
|
||||
|
||||
for row in rows:
|
||||
assert row["metric_errors"] == "weighted_distance"
|
||||
assert row["hop_count"] == 1 # still computed
|
||||
assert row["weighted_distance"] is None # failed
|
||||
assert row["semantic_similarity"] == 0.5 # still computed
|
||||
|
||||
def test_metric_errors_records_multiple_failures(self, exporter):
|
||||
"""When multiple metrics fail, all names appear comma-separated."""
|
||||
exporter._path_finder.bfs_shortest_path.side_effect = RuntimeError("fail")
|
||||
exporter._path_finder.dijkstra_shortest_path.side_effect = RuntimeError("fail")
|
||||
exporter._similarity.cosine_similarity.side_effect = TypeError("fail")
|
||||
exporter._centrality.calculate_betweenness_centrality.side_effect = RuntimeError("fail")
|
||||
|
||||
rows = exporter.compute_pairs(include=[
|
||||
"hop_count", "weighted_distance", "semantic_similarity",
|
||||
"source_betweenness", "metric_errors",
|
||||
])
|
||||
|
||||
for row in rows:
|
||||
errors = row["metric_errors"].split(",")
|
||||
assert "hop_count" in errors
|
||||
assert "weighted_distance" in errors
|
||||
assert "semantic_similarity" in errors
|
||||
assert "betweenness" in errors
|
||||
assert row["hop_count"] is None
|
||||
assert row["weighted_distance"] is None
|
||||
assert row["semantic_similarity"] is None
|
||||
|
||||
def test_metric_errors_absent_when_not_requested(self, exporter):
|
||||
"""When metric_errors is not in include, it doesn't appear in rows."""
|
||||
exporter._path_finder.bfs_shortest_path.side_effect = RuntimeError("fail")
|
||||
exporter._path_finder.dijkstra_shortest_path.return_value = {"total_weight": 1.0, "path": ["a", "b"]}
|
||||
exporter._similarity.cosine_similarity.return_value = 0.9
|
||||
|
||||
rows = exporter.compute_pairs(include=["hop_count", "weighted_distance", "semantic_similarity"])
|
||||
|
||||
for row in rows:
|
||||
assert "metric_errors" not in row
|
||||
|
||||
def test_metric_errors_distinguishes_no_path_from_error(self, exporter):
|
||||
"""Core distinction: None from 'no path' has empty error; None from exception has the metric name."""
|
||||
# bfs returns empty path (legitimate "no path") — NOT an error
|
||||
exporter._path_finder.bfs_shortest_path.return_value = {"path": []}
|
||||
# dijkstra raises (computation error)
|
||||
exporter._path_finder.dijkstra_shortest_path.side_effect = ValueError("bad weight")
|
||||
exporter._similarity.cosine_similarity.return_value = 0.3
|
||||
|
||||
rows = exporter.compute_pairs(include=["hop_count", "weighted_distance", "semantic_similarity", "metric_errors"])
|
||||
|
||||
for row in rows:
|
||||
# Both are None, but only weighted_distance is an error
|
||||
assert row["hop_count"] is None
|
||||
assert row["weighted_distance"] is None
|
||||
assert row["metric_errors"] == "weighted_distance"
|
||||
|
||||
def test_default_columns_unchanged_without_metric_errors(self, exporter):
|
||||
"""Default column set (no metric_errors) produces the same schema as before."""
|
||||
exporter._path_finder.bfs_shortest_path.return_value = {"path": ["a", "b"]}
|
||||
exporter._path_finder.dijkstra_shortest_path.return_value = {"total_weight": 1.0, "path": ["a", "b"]}
|
||||
exporter._similarity.cosine_similarity.return_value = 0.5
|
||||
exporter._centrality.calculate_betweenness_centrality.return_value = {"betweenness": {"a": 0.5, "b": 0.3}}
|
||||
|
||||
rows = exporter.compute_pairs()
|
||||
|
||||
assert len(rows) == 2
|
||||
expected_keys = {
|
||||
"source_id", "source_type", "target_id", "target_type",
|
||||
"hop_count", "weighted_distance", "semantic_similarity",
|
||||
"distance_band", "source_betweenness", "target_betweenness",
|
||||
}
|
||||
assert set(rows[0].keys()) == expected_keys
|
||||
assert "metric_errors" not in rows[0]
|
||||
Reference in New Issue
Block a user