diff --git a/cli/main.py b/cli/main.py index 2f6f3d0e..93d98a0f 100644 --- a/cli/main.py +++ b/cli/main.py @@ -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" @@ -32,6 +35,20 @@ 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) @@ -39,7 +56,10 @@ def main(args=None) -> int: 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": diff --git a/detection/feature_cache.py b/detection/feature_cache.py index 5d7a5b37..bff9c5d0 100644 --- a/detection/feature_cache.py +++ b/detection/feature_cache.py @@ -27,6 +27,7 @@ import pandas as pd from config import config +from detection.model_compatibility import FEATURE_CONTRACT_VERSION if TYPE_CHECKING: pass @@ -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__( @@ -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 @@ -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() @@ -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: diff --git a/detection/motif_census.py b/detection/motif_census.py index bdbe07a2..29b13936 100644 --- a/detection/motif_census.py +++ b/detection/motif_census.py @@ -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 diff --git a/tests/test_cli_operations.py b/tests/test_cli_operations.py index 4be0efdb..da645b58 100644 --- a/tests/test_cli_operations.py +++ b/tests/test_cli_operations.py @@ -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() diff --git a/tests/test_feature_cache.py b/tests/test_feature_cache.py index cdf64442..b50ebb49 100644 --- a/tests/test_feature_cache.py +++ b/tests/test_feature_cache.py @@ -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)) diff --git a/tests/test_motif_census.py b/tests/test_motif_census.py index 798e85f6..cb7bcad9 100644 --- a/tests/test_motif_census.py +++ b/tests/test_motif_census.py @@ -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() diff --git a/utils/diagnostics_checks.py b/utils/diagnostics_checks.py index 510c9153..792da877 100644 --- a/utils/diagnostics_checks.py +++ b/utils/diagnostics_checks.py @@ -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: @@ -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", )