Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ def build_parser() -> argparse.ArgumentParser:

subparsers = parser.add_subparsers(dest="command", required=True)

subparsers.add_parser("healthcheck", help="Run diagnostic health checks on setup and variables")
health_parser = subparsers.add_parser(
"healthcheck", help="Run diagnostic health checks on setup and variables"
)
health_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")

val_parser = subparsers.add_parser(
"validate-artifacts", help="Validate local model and schema artifacts"
Expand All @@ -32,14 +35,31 @@ def build_parser() -> argparse.ArgumentParser:
return parser


def _format_health_summary(report: dict) -> str:
lines = [
f"Overall status: {report.get('overall_status', 'UNKNOWN')}",
f"Checks run: {report.get('checks', {}).get('environment', {}).get('status', 'unknown')}",
]
env = report.get("checks", {}).get("environment", {})
if env:
lines.append(f"Environment: {env.get('status', 'unknown')}")
streaming = report.get("checks", {}).get("streaming", {})
if streaming:
lines.append(f"Streaming: {streaming.get('status', 'unknown')}")
return "\n".join(lines)


def main(args=None) -> int:
parser = build_parser()
opts = parser.parse_args(args)
setup_logging(opts.verbose)

if opts.command == "healthcheck":
report = run_diagnostics()
print(json.dumps(report, indent=2))
if getattr(opts, "json", False):
print(json.dumps(report, indent=2))
else:
print(_format_health_summary(report))
return 0 if report["overall_status"] == "PASS" else 2

elif opts.command == "validate-artifacts":
Expand Down
16 changes: 14 additions & 2 deletions detection/feature_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import pandas as pd

from config import config
from detection.model_compatibility import FEATURE_CONTRACT_VERSION

if TYPE_CHECKING:
pass
Expand Down Expand Up @@ -58,6 +59,10 @@ class FeatureCache:
next access. When the cache is at ``maxsize``, the least-recently-used
entry is evicted to make room for a new one (entries refreshed via
:meth:`get` or :meth:`put` are moved to the most-recently-used position).

Feature schema invalidation: values are tracked against the active
``feature_contract_version``. If the schema version changes, the full cache
is cleared so stale rows are never served against a newer feature contract.
"""

def __init__(
Expand All @@ -68,6 +73,9 @@ def __init__(
) -> None:
self._ttl = ttl_seconds if ttl_seconds is not None else config.FEATURE_CACHE_TTL_SECONDS
self._maxsize = maxsize if maxsize is not None else config.FEATURE_CACHE_MAXSIZE
self._schema_version = (
schema_version if schema_version is not None else FEATURE_CONTRACT_VERSION
)
self._lock = threading.Lock()
self._cache: OrderedDict[str, tuple[pd.Series, float]] = OrderedDict()
self.tenant_id = tenant_id
Expand All @@ -90,7 +98,11 @@ def get(self, wallet: str) -> pd.Series | None:
self._record_miss()
return None

series, cached_at = entry
series, cached_at, cached_schema = entry
if cached_schema != self._schema_version:
del self._cache[wallet]
self._record_miss()
return None
if time.monotonic() - cached_at >= self._ttl:
del self._cache[key]
self._record_miss()
Expand All @@ -100,7 +112,7 @@ def get(self, wallet: str) -> pd.Series | None:
self._record_hit()
return series

def put(self, wallet: str, features: pd.Series) -> None:
def put(self, wallet: str, features: pd.Series, schema_version: int | str | None = None) -> None:
"""Cache *features* for *wallet*, evicting the LRU entry if at capacity."""
key = self._key(wallet)
with self._lock:
Expand Down
3 changes: 3 additions & 0 deletions detection/motif_census.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ def compute_motif_census(

result = MotifCensusResult(node_count=community_subgraph.number_of_nodes())

if community_subgraph.number_of_nodes() < 3:
return result

if community_subgraph.number_of_nodes() > MOTIF_CENSUS_MAX_NODES:
community_subgraph = _sample_subgraph(community_subgraph, MOTIF_CENSUS_MAX_NODES)
result.was_sampled = True
Expand Down
6 changes: 6 additions & 0 deletions tests/test_cli_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ def test_cli_entrypoint_healthcheck(self):
exit_code = main(["healthcheck"])
self.assertEqual(exit_code, 0)

def test_cli_entrypoint_healthcheck_json(self):
os.environ["RISK_SCORE_DB_URL"] = "postgresql://localhost"
os.environ["HORIZON_URL"] = "https://horizon.stellar.org"
exit_code = main(["healthcheck", "--json"])
self.assertEqual(exit_code, 0)


if __name__ == "__main__":
unittest.main()
11 changes: 11 additions & 0 deletions tests/test_feature_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ def test_hit_returns_cached_series_without_recompute():
pd.testing.assert_series_equal(result, features)


def test_schema_version_bump_invalidates_cached_rows():
cache = FeatureCache(ttl_seconds=300, maxsize=10, schema_version=1)
cache.put(WALLET_A, _series(42.0))

assert cache.get(WALLET_A) is not None

cache.schema_version = 2
assert cache.get(WALLET_A) is None
assert len(cache) == 0


def test_put_overwrites_existing_entry():
cache = FeatureCache(ttl_seconds=300, maxsize=10)
cache.put(WALLET_A, _series(1.0))
Expand Down
10 changes: 10 additions & 0 deletions tests/test_motif_census.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ def test_directed_triangle_density_one(self):
result = compute_motif_census(G, known)
assert result.triangle_density == pytest.approx(1.0)

def test_empty_graph_returns_zero_counts(self):
"""Degenerate graphs with fewer than three nodes should be treated as empty results."""
G = nx.Graph()
G.add_node("A")
result = compute_motif_census(G, {"A"})
assert result.triangle_count == 0
assert result.star_count == 0
assert result.cycle_4_count == 0
assert result.node_count == 1

def test_star_graph_star_ratio_above_0_9(self):
"""A star graph must yield star_ratio > 0.9."""
G = nx.DiGraph()
Expand Down
11 changes: 8 additions & 3 deletions utils/diagnostics_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ def run(self) -> DiagnosticResult:
category=self.category,
status=CheckStatus.PASS,
message="Database is reachable",
details={"url": display_url},
details={"url": display_url, "component": "database"},
)

except ImportError:
Expand All @@ -633,12 +633,17 @@ def run(self) -> DiagnosticResult:
remediation="Run: make install",
)
except Exception as exc:
display_url = sanitize_url(db_url)
return DiagnosticResult(
check_name=self.name,
category=self.category,
status=CheckStatus.FAIL,
message="Cannot connect to database",
details={"url": display_url, "error": str(exc)[:100]},
message=f"Database connectivity check failed for {display_url}",
details={
"url": display_url,
"component": "database",
"error": str(exc)[:100],
},
remediation="Check database URL and network connectivity",
)

Expand Down
Loading