nat-engine 1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Coordinator Agent – implements the Extended Contract Net Protocol (ECNP).
|
|
7
|
+
|
|
8
|
+
The CoordinatorAgent receives TASK_ANNOUNCEMENT messages from PlannerAgents,
|
|
9
|
+
collects bids from ExecutorAgents over a configurable time window, evaluates
|
|
10
|
+
bids incorporating learned risk estimates, and awards contracts to the winning
|
|
11
|
+
bidder (Thesis §4.4).
|
|
12
|
+
|
|
13
|
+
ECNP cycle (per task):
|
|
14
|
+
1. Receive TASK_ANNOUNCEMENT from PlannerAgent.
|
|
15
|
+
2. Re-broadcast as announcement to ExecutorAgents (on TASK_ANNOUNCEMENT bus).
|
|
16
|
+
3. Collect TASK_BID messages during the bid window (``bid_timeout_s``).
|
|
17
|
+
4. Select winner: highest ``bid_value × risk_modulator``.
|
|
18
|
+
5. Publish CONTRACT_AWARD to the winning agent.
|
|
19
|
+
6. Wait for CONTRACT_RESULT; broadcast to PlannerAgents and AnalyzerAgents.
|
|
20
|
+
|
|
21
|
+
Safeguards (Thesis Appendix A.3):
|
|
22
|
+
* Renegotiation threshold: a task is re-announced if no bids arrive.
|
|
23
|
+
* Coordination rotation: coordinators can hand off if overloaded.
|
|
24
|
+
* Duplicate-contract prevention via task_id tracking.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import logging
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from typing import Any, Dict, List, Optional, Set
|
|
33
|
+
|
|
34
|
+
from mannf.core.agents.bdi_agent import BDIAgent
|
|
35
|
+
from mannf.core.messaging.bus import MessageBus
|
|
36
|
+
from mannf.core.messaging.messages import Message, MessageType
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CoordinatorAgent(BDIAgent):
|
|
42
|
+
"""Allocates testing tasks via the Extended Contract Net Protocol.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
bid_timeout_s:
|
|
47
|
+
How long (seconds) to wait for bids before awarding or re-announcing.
|
|
48
|
+
max_renegotiations:
|
|
49
|
+
Maximum times a task is re-announced if no bids arrive.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
agent_id: str,
|
|
55
|
+
bus: MessageBus,
|
|
56
|
+
service_names: List[str],
|
|
57
|
+
bid_timeout_s: float = 0.05,
|
|
58
|
+
max_renegotiations: int = 2,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(agent_id, bus, service_names)
|
|
61
|
+
self._bid_timeout_s = bid_timeout_s
|
|
62
|
+
self._max_renegotiations = max_renegotiations
|
|
63
|
+
|
|
64
|
+
# Pending bids: task_id → list of bid payloads
|
|
65
|
+
self._pending_bids: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
66
|
+
# Awarded tasks: task_id → winner_id (prevent duplicates)
|
|
67
|
+
self._awarded: Dict[str, str] = {}
|
|
68
|
+
# Re-announcement counters
|
|
69
|
+
self._renegotiation_counts: Dict[str, int] = defaultdict(int)
|
|
70
|
+
|
|
71
|
+
# Statistics
|
|
72
|
+
self._contracts_awarded = 0
|
|
73
|
+
self._contracts_completed = 0
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def subscribed_types(self) -> Set[MessageType]:
|
|
77
|
+
return {
|
|
78
|
+
MessageType.TASK_ANNOUNCEMENT,
|
|
79
|
+
MessageType.TASK_BID,
|
|
80
|
+
MessageType.CONTRACT_RESULT,
|
|
81
|
+
MessageType.BELIEF_UPDATE,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Message handling
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
async def _handle_message(self, message: Message) -> None:
|
|
89
|
+
if message.type == MessageType.TASK_ANNOUNCEMENT:
|
|
90
|
+
await self._handle_announcement(message)
|
|
91
|
+
elif message.type == MessageType.TASK_BID:
|
|
92
|
+
self._handle_bid(message)
|
|
93
|
+
elif message.type == MessageType.CONTRACT_RESULT:
|
|
94
|
+
await self._handle_contract_result(message)
|
|
95
|
+
elif message.type == MessageType.BELIEF_UPDATE:
|
|
96
|
+
self._absorb_peer_beliefs(
|
|
97
|
+
message.sender_id,
|
|
98
|
+
message.payload.get("beliefs", {}),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# ECNP: announcement phase
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def _handle_announcement(self, message: Message) -> None:
|
|
106
|
+
"""Receive task from PlannerAgent and open bid collection."""
|
|
107
|
+
# Ignore own re-broadcasts to prevent infinite loops
|
|
108
|
+
if message.sender_id == self.agent_id:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
task = message.payload or {}
|
|
112
|
+
task_id = task.get("task_id")
|
|
113
|
+
if not task_id or task_id in self._awarded:
|
|
114
|
+
return
|
|
115
|
+
# Deduplicate: ignore if already being processed
|
|
116
|
+
if task_id in self._pending_bids or task_id in self._renegotiation_counts:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# Re-broadcast the announcement so ExecutorAgents see it
|
|
120
|
+
await self._publish(
|
|
121
|
+
Message(MessageType.TASK_ANNOUNCEMENT, self.agent_id, task)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Emit ECNP "offer" telemetry event (fail-safe)
|
|
125
|
+
try:
|
|
126
|
+
from mannf.dashboard import telemetry as _tel
|
|
127
|
+
await _tel.broadcast_ecnp_event(
|
|
128
|
+
event_kind="offer",
|
|
129
|
+
sender_id=self.agent_id,
|
|
130
|
+
contract_id=task_id,
|
|
131
|
+
details={
|
|
132
|
+
"target": task.get("target", ""),
|
|
133
|
+
"priority": task.get("priority", 0.0),
|
|
134
|
+
"risk_estimate": task.get("risk_estimate", 0.0),
|
|
135
|
+
"planner_id": task.get("planner_id", ""),
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
except Exception: # noqa: BLE001
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# Wait for bids then award
|
|
142
|
+
asyncio.get_running_loop().create_task(
|
|
143
|
+
self._award_after_timeout(task)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# ECNP: bid collection
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _handle_bid(self, message: Message) -> None:
|
|
151
|
+
bid = message.payload or {}
|
|
152
|
+
task_id = bid.get("task_id")
|
|
153
|
+
if task_id and task_id not in self._awarded:
|
|
154
|
+
self._pending_bids[task_id].append(bid)
|
|
155
|
+
logger.debug(
|
|
156
|
+
"CoordinatorAgent %s: received bid %.3f from %s for task %s",
|
|
157
|
+
self.agent_id, bid.get("bid_value", 0.0),
|
|
158
|
+
bid.get("bidder_id", "?"), task_id,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# ECNP: award phase
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
async def _award_after_timeout(self, task: Dict[str, Any]) -> None:
|
|
166
|
+
"""Wait for bid_timeout, then award to highest bidder."""
|
|
167
|
+
await asyncio.sleep(self._bid_timeout_s)
|
|
168
|
+
|
|
169
|
+
task_id = task.get("task_id")
|
|
170
|
+
if not task_id or task_id in self._awarded:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
bids = self._pending_bids.pop(task_id, [])
|
|
174
|
+
if not bids:
|
|
175
|
+
# No bids – renegotiate if allowed
|
|
176
|
+
count = self._renegotiation_counts[task_id]
|
|
177
|
+
if count < self._max_renegotiations:
|
|
178
|
+
self._renegotiation_counts[task_id] = count + 1
|
|
179
|
+
logger.debug(
|
|
180
|
+
"CoordinatorAgent %s: no bids for task %s; re-announcing (%d/%d)",
|
|
181
|
+
self.agent_id, task_id, count + 1, self._max_renegotiations,
|
|
182
|
+
)
|
|
183
|
+
await self._publish(
|
|
184
|
+
Message(MessageType.TASK_ANNOUNCEMENT, self.agent_id, task)
|
|
185
|
+
)
|
|
186
|
+
asyncio.get_running_loop().create_task(
|
|
187
|
+
self._award_after_timeout(task)
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
# Select winner: highest bid_value (risk-modulated)
|
|
192
|
+
winner_bid = max(bids, key=lambda b: b.get("bid_value", 0.0))
|
|
193
|
+
winner_id = winner_bid.get("bidder_id", "")
|
|
194
|
+
|
|
195
|
+
self._awarded[task_id] = winner_id
|
|
196
|
+
self._contracts_awarded += 1
|
|
197
|
+
|
|
198
|
+
contract = {
|
|
199
|
+
**task,
|
|
200
|
+
"winner_id": winner_id,
|
|
201
|
+
"winning_bid": winner_bid.get("bid_value", 0.0),
|
|
202
|
+
}
|
|
203
|
+
await self._publish(
|
|
204
|
+
Message(MessageType.CONTRACT_AWARD, self.agent_id, contract)
|
|
205
|
+
)
|
|
206
|
+
logger.debug(
|
|
207
|
+
"CoordinatorAgent %s: awarded task %s to %s (bid=%.3f)",
|
|
208
|
+
self.agent_id, task_id, winner_id, winner_bid.get("bid_value", 0.0),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Emit ECNP "award" telemetry event (fail-safe)
|
|
212
|
+
try:
|
|
213
|
+
from mannf.dashboard import telemetry as _tel
|
|
214
|
+
await _tel.broadcast_ecnp_event(
|
|
215
|
+
event_kind="award",
|
|
216
|
+
sender_id=self.agent_id,
|
|
217
|
+
contract_id=task_id,
|
|
218
|
+
details={
|
|
219
|
+
"winner_id": winner_id,
|
|
220
|
+
"winning_bid": winner_bid.get("bid_value", 0.0),
|
|
221
|
+
"target": task.get("target", ""),
|
|
222
|
+
"all_bids": [
|
|
223
|
+
{"bidder_id": b.get("bidder_id"), "bid_value": b.get("bid_value", 0.0)}
|
|
224
|
+
for b in bids
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
except Exception: # noqa: BLE001
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# ECNP: completion
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
async def _handle_contract_result(self, message: Message) -> None:
|
|
236
|
+
"""Process execution outcome and update own beliefs.
|
|
237
|
+
|
|
238
|
+
Planners and Analyzers already subscribe directly to CONTRACT_RESULT
|
|
239
|
+
from Executors, so we don't re-broadcast to avoid message storms.
|
|
240
|
+
"""
|
|
241
|
+
payload = message.payload or {}
|
|
242
|
+
self._contracts_completed += 1
|
|
243
|
+
|
|
244
|
+
# Update own beliefs based on outcome
|
|
245
|
+
target = payload.get("target", "")
|
|
246
|
+
if target:
|
|
247
|
+
passed = payload.get("passed", True)
|
|
248
|
+
error = payload.get("error")
|
|
249
|
+
evidence = 0.0 if passed else (0.5 if error else 1.0)
|
|
250
|
+
self._update_belief_from_result(target, evidence)
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
# Diagnostics
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def contracts_awarded(self) -> int:
|
|
258
|
+
return self._contracts_awarded
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def contracts_completed(self) -> int:
|
|
262
|
+
return self._contracts_completed
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Executor Agent – executes test cases awarded by the CoordinatorAgent.
|
|
7
|
+
|
|
8
|
+
The ExecutorAgent receives CONTRACT_AWARD messages, executes the requested
|
|
9
|
+
test against the system under test, and publishes CONTRACT_RESULT messages
|
|
10
|
+
back to the coordinator (Thesis §4.2).
|
|
11
|
+
|
|
12
|
+
Bidding model (ECNP §4.4):
|
|
13
|
+
* Bid value = expected informational gain, computed as:
|
|
14
|
+
bid = own_fault_belief[target] × (1 − recent_execution_load)
|
|
15
|
+
* Higher fault belief + lower load → higher bid → more likely to win.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import random
|
|
22
|
+
import string
|
|
23
|
+
import uuid
|
|
24
|
+
from collections import deque
|
|
25
|
+
from typing import Any, Deque, Dict, List, Optional, Set
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from mannf.core.agents.bdi_agent import BDIAgent
|
|
30
|
+
from mannf.core.distributed.system_under_test import SystemUnderTest
|
|
31
|
+
from mannf.core.messaging.bus import MessageBus
|
|
32
|
+
from mannf.core.messaging.messages import Message, MessageType
|
|
33
|
+
from mannf.core.testing.models import TestCase, TestResult
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_LOAD_WINDOW = 10 # sliding window for execution load estimation
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExecutorAgent(BDIAgent):
|
|
41
|
+
"""Executes test cases through ECNP contract bidding.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
sut:
|
|
46
|
+
The system under test to execute against.
|
|
47
|
+
max_concurrent:
|
|
48
|
+
Maximum simultaneous executions (backpressure signal to bids).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
agent_id: str,
|
|
54
|
+
bus: MessageBus,
|
|
55
|
+
service_names: List[str],
|
|
56
|
+
sut: SystemUnderTest,
|
|
57
|
+
max_concurrent: int = 3,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(agent_id, bus, service_names)
|
|
60
|
+
self._sut = sut
|
|
61
|
+
self._max_concurrent = max_concurrent
|
|
62
|
+
self._active: int = 0
|
|
63
|
+
self._latency_window: Deque[float] = deque(maxlen=_LOAD_WINDOW)
|
|
64
|
+
self._executions = 0
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def subscribed_types(self) -> Set[MessageType]:
|
|
68
|
+
return {
|
|
69
|
+
MessageType.TASK_ANNOUNCEMENT, # evaluate and potentially bid
|
|
70
|
+
MessageType.CONTRACT_AWARD, # execute if we won
|
|
71
|
+
MessageType.BELIEF_UPDATE, # absorb peer beliefs
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Message handling
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async def _handle_message(self, message: Message) -> None:
|
|
79
|
+
if message.type == MessageType.TASK_ANNOUNCEMENT:
|
|
80
|
+
await self._evaluate_and_bid(message)
|
|
81
|
+
elif message.type == MessageType.CONTRACT_AWARD:
|
|
82
|
+
if message.payload.get("winner_id") == self.agent_id:
|
|
83
|
+
await self._execute_contract(message.payload)
|
|
84
|
+
elif message.type == MessageType.BELIEF_UPDATE:
|
|
85
|
+
self._absorb_peer_beliefs(
|
|
86
|
+
message.sender_id,
|
|
87
|
+
message.payload.get("beliefs", {}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# ECNP bidding
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
async def _evaluate_and_bid(self, announcement: Message) -> None:
|
|
95
|
+
"""Compute a bid value and submit it to the CoordinatorAgent."""
|
|
96
|
+
task = announcement.payload or {}
|
|
97
|
+
target = task.get("target", "")
|
|
98
|
+
if not target:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Bid = fault-likelihood belief × availability factor
|
|
102
|
+
fault_belief = self.beliefs.fault_likelihood.get(target, 0.5)
|
|
103
|
+
load_factor = 1.0 - (self._active / max(self._max_concurrent, 1))
|
|
104
|
+
bid_value = fault_belief * max(0.1, load_factor)
|
|
105
|
+
|
|
106
|
+
await self._publish(
|
|
107
|
+
Message(
|
|
108
|
+
MessageType.TASK_BID,
|
|
109
|
+
sender_id=self.agent_id,
|
|
110
|
+
payload={
|
|
111
|
+
"task_id": task.get("task_id"),
|
|
112
|
+
"target": target,
|
|
113
|
+
"bid_value": bid_value,
|
|
114
|
+
"bidder_id": self.agent_id,
|
|
115
|
+
},
|
|
116
|
+
correlation_id=announcement.id,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
logger.debug(
|
|
120
|
+
"ExecutorAgent %s: bid %.3f for %s",
|
|
121
|
+
self.agent_id, bid_value, target,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Emit ECNP "bid" telemetry event (fail-safe)
|
|
125
|
+
try:
|
|
126
|
+
from mannf.dashboard import telemetry as _tel
|
|
127
|
+
await _tel.broadcast_ecnp_event(
|
|
128
|
+
event_kind="bid",
|
|
129
|
+
sender_id=self.agent_id,
|
|
130
|
+
contract_id=task.get("task_id", ""),
|
|
131
|
+
details={
|
|
132
|
+
"target": target,
|
|
133
|
+
"bid_value": round(bid_value, 4),
|
|
134
|
+
"load_factor": round(max(0.1, load_factor), 4),
|
|
135
|
+
"fault_belief": round(fault_belief, 4),
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
except Exception: # noqa: BLE001
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Contract execution
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
async def _execute_contract(self, contract: Dict[str, Any]) -> None:
|
|
146
|
+
"""Execute the awarded test contract against the SUT."""
|
|
147
|
+
target = contract.get("target", "")
|
|
148
|
+
if not target:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
self._active += 1
|
|
152
|
+
test_case = self._build_test_case(target, contract)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
result = await self._sut.execute(test_case)
|
|
156
|
+
result.agent_id = self.agent_id
|
|
157
|
+
self._executions += 1
|
|
158
|
+
|
|
159
|
+
# Update own beliefs based on outcome
|
|
160
|
+
evidence = 0.0 if result.passed else (0.5 if result.error else 1.0)
|
|
161
|
+
self._update_belief_from_result(target, evidence)
|
|
162
|
+
|
|
163
|
+
if result.execution_time_ms:
|
|
164
|
+
self._latency_window.append(result.execution_time_ms)
|
|
165
|
+
|
|
166
|
+
# Publish result back to the bus (monitor/oracle listen)
|
|
167
|
+
await self._publish(
|
|
168
|
+
Message(
|
|
169
|
+
MessageType.TEST_RESULT,
|
|
170
|
+
sender_id=self.agent_id,
|
|
171
|
+
payload={"result": result, "test_case": test_case},
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
# Notify coordinator of completion
|
|
175
|
+
await self._publish(
|
|
176
|
+
Message(
|
|
177
|
+
MessageType.CONTRACT_RESULT,
|
|
178
|
+
sender_id=self.agent_id,
|
|
179
|
+
payload={
|
|
180
|
+
"task_id": contract.get("task_id"),
|
|
181
|
+
"target": target,
|
|
182
|
+
"passed": result.passed,
|
|
183
|
+
"error": result.error,
|
|
184
|
+
"execution_time_ms": result.execution_time_ms,
|
|
185
|
+
"executor_id": self.agent_id,
|
|
186
|
+
"test_case": test_case,
|
|
187
|
+
"result": result,
|
|
188
|
+
},
|
|
189
|
+
correlation_id=contract.get("task_id"),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
logger.debug(
|
|
193
|
+
"ExecutorAgent %s: executed %s → %s",
|
|
194
|
+
self.agent_id, target, "PASS" if result.passed else "FAIL",
|
|
195
|
+
)
|
|
196
|
+
finally:
|
|
197
|
+
self._active -= 1
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _build_test_case(target: str, contract: Dict[str, Any]) -> TestCase:
|
|
201
|
+
inputs = {
|
|
202
|
+
"request_id": str(uuid.uuid4()),
|
|
203
|
+
"user_id": random.randint(1, 10_000),
|
|
204
|
+
"action": random.choice(["create", "read", "update", "delete"]),
|
|
205
|
+
"auth_token": "".join(random.choices(string.ascii_letters, k=16)),
|
|
206
|
+
"payload_size": random.randint(1, 1024),
|
|
207
|
+
}
|
|
208
|
+
return TestCase(
|
|
209
|
+
target=target,
|
|
210
|
+
inputs=inputs,
|
|
211
|
+
expected_behavior=f"Service {target!r} returns a successful response",
|
|
212
|
+
priority=contract.get("risk_estimate", 0.5),
|
|
213
|
+
metadata={"task_id": contract.get("task_id", ""), "executor": contract.get("winner_id", "")},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def execution_count(self) -> int:
|
|
218
|
+
return self._executions
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def avg_latency_ms(self) -> float:
|
|
222
|
+
return float(np.mean(list(self._latency_window))) if self._latency_window else 0.0
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Monitor agent.
|
|
7
|
+
|
|
8
|
+
The :class:`MonitorAgent` collects execution metrics from
|
|
9
|
+
:class:`TestResult` messages and uses an anomaly-detection neural network
|
|
10
|
+
to flag unusual system behaviour. It publishes
|
|
11
|
+
:attr:`MessageType.METRICS_UPDATE` and :attr:`MessageType.ANOMALY_DETECTED`
|
|
12
|
+
messages that other agents can react to.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
from collections import deque
|
|
20
|
+
from typing import Any, Deque, Dict, List, Optional, Set, Tuple
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from mannf.core.agents.base import BaseAgent
|
|
25
|
+
from mannf.core.messaging.bus import MessageBus
|
|
26
|
+
from mannf.core.messaging.messages import Message, MessageType
|
|
27
|
+
from mannf.core.neural import NeuralNetwork
|
|
28
|
+
from mannf.core.testing.models import TestResult
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_METRIC_DIM = 6 # dimensionality of the metric feature vector
|
|
33
|
+
_HISTORY = 50 # sliding window length for anomaly detection baseline
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MonitorAgent(BaseAgent):
|
|
37
|
+
"""Monitors test execution metrics and detects anomalies.
|
|
38
|
+
|
|
39
|
+
The agent maintains a sliding window of recent test results and trains
|
|
40
|
+
an autoencoder-style anomaly detector: inputs ≈ outputs when normal;
|
|
41
|
+
high reconstruction error signals an anomaly.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, agent_id: str, bus: MessageBus) -> None:
|
|
45
|
+
# Autoencoder: compress 6-D metrics to 3-D then reconstruct
|
|
46
|
+
network = NeuralNetwork(
|
|
47
|
+
layer_sizes=[_METRIC_DIM, 12, 3, 12, _METRIC_DIM],
|
|
48
|
+
hidden_activation="relu",
|
|
49
|
+
learning_rate=0.005,
|
|
50
|
+
)
|
|
51
|
+
super().__init__(agent_id, bus, network)
|
|
52
|
+
|
|
53
|
+
self._history: Deque[np.ndarray] = deque(maxlen=_HISTORY)
|
|
54
|
+
self._total_tests = 0
|
|
55
|
+
self._total_failures = 0
|
|
56
|
+
self._total_errors = 0
|
|
57
|
+
self._latency_window: Deque[float] = deque(maxlen=_HISTORY)
|
|
58
|
+
|
|
59
|
+
# Dynamic threshold for anomaly detection (updated per batch)
|
|
60
|
+
self._anomaly_threshold: float = 1.0
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def subscribed_types(self) -> Set[MessageType]:
|
|
64
|
+
return {MessageType.TEST_RESULT}
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# Message handling
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
async def _handle_message(self, message: Message) -> None:
|
|
71
|
+
if message.type == MessageType.TEST_RESULT:
|
|
72
|
+
payload = message.payload
|
|
73
|
+
result: TestResult = (
|
|
74
|
+
payload.get("result") if isinstance(payload, dict) else payload
|
|
75
|
+
)
|
|
76
|
+
if isinstance(result, TestResult):
|
|
77
|
+
self._ingest(result)
|
|
78
|
+
|
|
79
|
+
async def _on_idle(self) -> None:
|
|
80
|
+
"""Periodically broadcast a metrics snapshot."""
|
|
81
|
+
if self._total_tests > 0:
|
|
82
|
+
snapshot = self._metrics_snapshot()
|
|
83
|
+
await self._publish(
|
|
84
|
+
Message(MessageType.METRICS_UPDATE, self.agent_id, snapshot)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Metric ingestion and anomaly detection
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _ingest(self, result: TestResult) -> None:
|
|
92
|
+
self._total_tests += 1
|
|
93
|
+
if result.error:
|
|
94
|
+
self._total_errors += 1
|
|
95
|
+
elif not result.passed:
|
|
96
|
+
self._total_failures += 1
|
|
97
|
+
self._latency_window.append(result.execution_time_ms)
|
|
98
|
+
|
|
99
|
+
vec = self._result_to_vector(result)
|
|
100
|
+
self._history.append(vec)
|
|
101
|
+
|
|
102
|
+
# Train autoencoder on normal data (passed tests only)
|
|
103
|
+
if result.passed and not result.error and self.network is not None:
|
|
104
|
+
self.network.train_step(vec, vec)
|
|
105
|
+
|
|
106
|
+
# Check for anomaly once we have a baseline
|
|
107
|
+
if len(self._history) >= 10:
|
|
108
|
+
self._check_anomaly(result, vec)
|
|
109
|
+
|
|
110
|
+
def _result_to_vector(self, result: TestResult) -> np.ndarray:
|
|
111
|
+
"""Encode a TestResult into a fixed-size numeric vector."""
|
|
112
|
+
vec = np.zeros(_METRIC_DIM)
|
|
113
|
+
vec[0] = 1.0 if result.passed else 0.0
|
|
114
|
+
vec[1] = 1.0 if result.error else 0.0
|
|
115
|
+
vec[2] = min(result.execution_time_ms / 1000.0, 5.0) # cap at 5 s
|
|
116
|
+
vec[3] = self._failure_rate()
|
|
117
|
+
vec[4] = self._error_rate()
|
|
118
|
+
vec[5] = self._avg_latency() / 1000.0
|
|
119
|
+
return vec
|
|
120
|
+
|
|
121
|
+
def _check_anomaly(self, result: TestResult, vec: np.ndarray) -> None:
|
|
122
|
+
"""Use the autoencoder reconstruction error to detect anomalies."""
|
|
123
|
+
assert self.network is not None
|
|
124
|
+
recon = self.network.predict(vec)
|
|
125
|
+
error = float(np.mean((vec - recon) ** 2))
|
|
126
|
+
|
|
127
|
+
# Update threshold as 2× median of recent reconstruction errors
|
|
128
|
+
recent_errors = [
|
|
129
|
+
float(np.mean((v - self.network.predict(v)) ** 2))
|
|
130
|
+
for v in list(self._history)[-20:]
|
|
131
|
+
]
|
|
132
|
+
if recent_errors:
|
|
133
|
+
self._anomaly_threshold = max(2.0 * float(np.median(recent_errors)), 1e-6)
|
|
134
|
+
|
|
135
|
+
if error > self._anomaly_threshold:
|
|
136
|
+
logger.info(
|
|
137
|
+
"MonitorAgent %s: anomaly detected (error=%.4f > threshold=%.4f)",
|
|
138
|
+
self.agent_id, error, self._anomaly_threshold,
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
loop = asyncio.get_running_loop()
|
|
142
|
+
loop.create_task(
|
|
143
|
+
self._publish(
|
|
144
|
+
Message(
|
|
145
|
+
MessageType.ANOMALY_DETECTED,
|
|
146
|
+
self.agent_id,
|
|
147
|
+
{
|
|
148
|
+
"test_case_id": result.test_case_id,
|
|
149
|
+
"reconstruction_error": error,
|
|
150
|
+
"threshold": self._anomaly_threshold,
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
except RuntimeError:
|
|
156
|
+
pass # No running event loop; skip async publish
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
# Statistics helpers
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def _failure_rate(self) -> float:
|
|
163
|
+
return self._total_failures / self._total_tests if self._total_tests else 0.0
|
|
164
|
+
|
|
165
|
+
def _error_rate(self) -> float:
|
|
166
|
+
return self._total_errors / self._total_tests if self._total_tests else 0.0
|
|
167
|
+
|
|
168
|
+
def _avg_latency(self) -> float:
|
|
169
|
+
if not self._latency_window:
|
|
170
|
+
return 0.0
|
|
171
|
+
return float(np.mean(list(self._latency_window)))
|
|
172
|
+
|
|
173
|
+
def _metrics_snapshot(self) -> Dict[str, Any]:
|
|
174
|
+
return {
|
|
175
|
+
"agent_id": self.agent_id,
|
|
176
|
+
"total_tests": self._total_tests,
|
|
177
|
+
"total_failures": self._total_failures,
|
|
178
|
+
"total_errors": self._total_errors,
|
|
179
|
+
"failure_rate": round(self._failure_rate(), 4),
|
|
180
|
+
"error_rate": round(self._error_rate(), 4),
|
|
181
|
+
"avg_latency_ms": round(self._avg_latency(), 2),
|
|
182
|
+
"anomaly_threshold": round(self._anomaly_threshold, 6),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def metrics(self) -> Dict[str, Any]:
|
|
187
|
+
return self._metrics_snapshot()
|
|
188
|
+
|