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,342 @@
|
|
|
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
|
+
"""NAT Orchestrator – full NeuroAgentTest BDI/ECNP architecture.
|
|
7
|
+
|
|
8
|
+
This orchestrator implements the complete NAT framework as described in
|
|
9
|
+
Chapter 4 of the thesis, using:
|
|
10
|
+
|
|
11
|
+
* **PlannerAgents** – formulate test objectives from belief state
|
|
12
|
+
* **ExecutorAgents** – bid on and execute contracts via ECNP
|
|
13
|
+
* **AnalyzerAgents** – process results, update beliefs, detect anomalies
|
|
14
|
+
* **CoordinatorAgent** – allocates tasks via Extended Contract Net Protocol
|
|
15
|
+
|
|
16
|
+
All agents run concurrently as ``asyncio`` tasks and communicate through the
|
|
17
|
+
shared :class:`MessageBus`. The orchestrator drives agent lifecycle and
|
|
18
|
+
produces the final report.
|
|
19
|
+
|
|
20
|
+
Emergence: unlike the simple round-robin :class:`Orchestrator`, this
|
|
21
|
+
implementation produces emergent specialisation through ECNP negotiation –
|
|
22
|
+
services with higher fault likelihood attract more testing attention without
|
|
23
|
+
any explicit assignment.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import logging
|
|
30
|
+
import time
|
|
31
|
+
from typing import Any, Dict, List, Optional
|
|
32
|
+
|
|
33
|
+
from mannf.core.agents.analyzer_agent import AnalyzerAgent
|
|
34
|
+
from mannf.core.agents.coordinator_agent import CoordinatorAgent
|
|
35
|
+
from mannf.core.agents.executor_agent import ExecutorAgent
|
|
36
|
+
from mannf.core.agents.planner_agent import PlannerAgent
|
|
37
|
+
from mannf.core.anomaly.enhanced_detector import EnhancedAnomalyDetector
|
|
38
|
+
from mannf.core.distributed.system_under_test import SystemUnderTest
|
|
39
|
+
from mannf.core.messaging.bus import MessageBus
|
|
40
|
+
from mannf.core.messaging.messages import Message, MessageType
|
|
41
|
+
from mannf.core.prioritization.belief_prioritizer import BeliefPrioritizer
|
|
42
|
+
from mannf.core.prioritization.risk_scorer import RiskScorer
|
|
43
|
+
from mannf.core.testing.models import TestResult, TestSuite
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _infer_status_code(result: TestResult, actual: Any) -> int:
|
|
49
|
+
"""Infer an HTTP status code from a test result for anomaly detection."""
|
|
50
|
+
if result.error:
|
|
51
|
+
return 500
|
|
52
|
+
if isinstance(actual, dict) and "status_code" in actual:
|
|
53
|
+
try:
|
|
54
|
+
return int(actual["status_code"])
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
pass
|
|
57
|
+
return 200 if result.passed else 400
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NATOrchestrator:
|
|
61
|
+
"""Full NeuroAgentTest orchestrator using BDI agents and ECNP.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
sut:
|
|
66
|
+
The system under test.
|
|
67
|
+
num_planners:
|
|
68
|
+
Number of PlannerAgent instances.
|
|
69
|
+
num_executors:
|
|
70
|
+
Number of ExecutorAgent instances.
|
|
71
|
+
num_analyzers:
|
|
72
|
+
Number of AnalyzerAgent instances.
|
|
73
|
+
max_contracts:
|
|
74
|
+
Stop after this many contracts have been completed.
|
|
75
|
+
suite_name:
|
|
76
|
+
Label for the test suite in the final report.
|
|
77
|
+
log_interval:
|
|
78
|
+
Log a progress summary every *log_interval* completions.
|
|
79
|
+
bid_timeout_s:
|
|
80
|
+
ECNP bid collection window (seconds).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
sut: SystemUnderTest,
|
|
86
|
+
num_planners: int = 2,
|
|
87
|
+
num_executors: int = 3,
|
|
88
|
+
num_analyzers: int = 1,
|
|
89
|
+
max_contracts: int = 100,
|
|
90
|
+
suite_name: str = "NAT Run",
|
|
91
|
+
log_interval: int = 10,
|
|
92
|
+
bid_timeout_s: float = 0.05,
|
|
93
|
+
belief_prioritizer: Optional[BeliefPrioritizer] = None,
|
|
94
|
+
anomaly_detector: Optional[EnhancedAnomalyDetector] = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self.sut = sut
|
|
97
|
+
self.max_contracts = max_contracts
|
|
98
|
+
self.log_interval = log_interval
|
|
99
|
+
self.belief_prioritizer = belief_prioritizer
|
|
100
|
+
self.anomaly_detector = anomaly_detector
|
|
101
|
+
|
|
102
|
+
self._bus = MessageBus()
|
|
103
|
+
self._suite = TestSuite(name=suite_name)
|
|
104
|
+
|
|
105
|
+
service_names = [e.name for e in sut.registry.all()]
|
|
106
|
+
|
|
107
|
+
# Risk scorer is always active; it feeds get_risk_report()
|
|
108
|
+
self._risk_scorer = RiskScorer()
|
|
109
|
+
|
|
110
|
+
# Create agents
|
|
111
|
+
self._planners: List[PlannerAgent] = [
|
|
112
|
+
PlannerAgent(f"planner-{i}", self._bus, service_names)
|
|
113
|
+
for i in range(num_planners)
|
|
114
|
+
]
|
|
115
|
+
self._executors: List[ExecutorAgent] = [
|
|
116
|
+
ExecutorAgent(f"executor-{i}", self._bus, service_names, sut)
|
|
117
|
+
for i in range(num_executors)
|
|
118
|
+
]
|
|
119
|
+
self._analyzers: List[AnalyzerAgent] = [
|
|
120
|
+
AnalyzerAgent(f"analyzer-{i}", self._bus, service_names)
|
|
121
|
+
for i in range(num_analyzers)
|
|
122
|
+
]
|
|
123
|
+
self._coordinator = CoordinatorAgent(
|
|
124
|
+
"coordinator-0", self._bus, service_names, bid_timeout_s=bid_timeout_s
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Subscribe orchestrator to monitor completion
|
|
128
|
+
# TEST_RESULT is published once per execution by ExecutorAgents
|
|
129
|
+
self._result_queue = self._bus.subscribe(
|
|
130
|
+
"nat-orchestrator",
|
|
131
|
+
{MessageType.TEST_RESULT},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self._completed = 0
|
|
135
|
+
self._start_time: Optional[float] = None
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Main entry point
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
async def run(self) -> TestSuite:
|
|
142
|
+
"""Launch all agents and wait for max_contracts completions."""
|
|
143
|
+
self._start_time = time.monotonic()
|
|
144
|
+
logger.info("NATOrchestrator: starting (max_contracts=%d)", self.max_contracts)
|
|
145
|
+
|
|
146
|
+
all_agents = (
|
|
147
|
+
self._planners + self._executors + self._analyzers + [self._coordinator]
|
|
148
|
+
)
|
|
149
|
+
tasks = [asyncio.create_task(a.run()) for a in all_agents]
|
|
150
|
+
|
|
151
|
+
# Give agents time to subscribe and start their run() loops
|
|
152
|
+
await asyncio.sleep(0)
|
|
153
|
+
|
|
154
|
+
# Kickstart the pipeline: trigger initial task announcements from all
|
|
155
|
+
# planners so executors have something to bid on immediately
|
|
156
|
+
for planner in self._planners:
|
|
157
|
+
asyncio.get_running_loop().create_task(planner._announce_tasks())
|
|
158
|
+
|
|
159
|
+
# Allow announcements to propagate before monitoring begins
|
|
160
|
+
await asyncio.sleep(0.01)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
await self._monitor_completions()
|
|
164
|
+
finally:
|
|
165
|
+
for a in all_agents:
|
|
166
|
+
a._running = False
|
|
167
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
168
|
+
|
|
169
|
+
elapsed = time.monotonic() - (self._start_time or 0)
|
|
170
|
+
logger.info(
|
|
171
|
+
"NATOrchestrator: finished %d contracts in %.1fs",
|
|
172
|
+
self._completed, elapsed,
|
|
173
|
+
)
|
|
174
|
+
return self._suite
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Completion monitoring
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
async def _monitor_completions(self) -> None:
|
|
181
|
+
"""Read CONTRACT_RESULT / TEST_RESULT messages and build the suite."""
|
|
182
|
+
while self._completed < self.max_contracts:
|
|
183
|
+
try:
|
|
184
|
+
msg = await asyncio.wait_for(self._result_queue.get(), timeout=5.0)
|
|
185
|
+
except asyncio.TimeoutError:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"NATOrchestrator: no results after 5s (completed=%d)",
|
|
188
|
+
self._completed,
|
|
189
|
+
)
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
payload = msg.payload
|
|
193
|
+
result: Optional[TestResult] = None
|
|
194
|
+
test_case = None
|
|
195
|
+
|
|
196
|
+
if isinstance(payload, dict):
|
|
197
|
+
result = payload.get("result")
|
|
198
|
+
test_case = payload.get("test_case")
|
|
199
|
+
elif isinstance(payload, TestResult):
|
|
200
|
+
result = payload
|
|
201
|
+
|
|
202
|
+
if result is None:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if test_case is not None:
|
|
206
|
+
self._suite.add_case(test_case)
|
|
207
|
+
self._suite.record_result(result)
|
|
208
|
+
self._completed += 1
|
|
209
|
+
|
|
210
|
+
# Update belief prioritizer and risk scorer with the result
|
|
211
|
+
target = test_case.target if test_case is not None else "unknown"
|
|
212
|
+
if self.belief_prioritizer is not None:
|
|
213
|
+
self.belief_prioritizer.record_result(result, target)
|
|
214
|
+
|
|
215
|
+
belief_states = [a.beliefs for a in self._analyzers]
|
|
216
|
+
self._risk_scorer.record(target, result, belief_states)
|
|
217
|
+
|
|
218
|
+
# Run anomaly detection on the result
|
|
219
|
+
if self.anomaly_detector is not None:
|
|
220
|
+
actual = result.actual_output
|
|
221
|
+
self.anomaly_detector.observe(
|
|
222
|
+
endpoint=target,
|
|
223
|
+
latency_ms=result.execution_time_ms,
|
|
224
|
+
status_code=_infer_status_code(result, actual),
|
|
225
|
+
payload=actual,
|
|
226
|
+
is_error=bool(result.error),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if self._completed % self.log_interval == 0:
|
|
230
|
+
self._log_progress()
|
|
231
|
+
|
|
232
|
+
def _log_progress(self) -> None:
|
|
233
|
+
s = self._suite.summary()
|
|
234
|
+
elapsed = time.monotonic() - (self._start_time or time.monotonic())
|
|
235
|
+
# Aggregate belief state from all analyzers
|
|
236
|
+
avg_entropy = (
|
|
237
|
+
sum(a.beliefs.entropy() for a in self._analyzers) / len(self._analyzers)
|
|
238
|
+
if self._analyzers else 0.0
|
|
239
|
+
)
|
|
240
|
+
logger.info(
|
|
241
|
+
"NAT %4d/%d | passed=%d failed=%d errored=%d "
|
|
242
|
+
"| defect_rate=%.1f%% | avg_belief_entropy=%.3f "
|
|
243
|
+
"| contracts_awarded=%d | elapsed=%.1fs",
|
|
244
|
+
self._completed, self.max_contracts,
|
|
245
|
+
s["passed"], s["failed"], s["errored"],
|
|
246
|
+
s["defect_detection_rate"] * 100,
|
|
247
|
+
avg_entropy,
|
|
248
|
+
self._coordinator.contracts_awarded,
|
|
249
|
+
elapsed,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
# Report
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def report(self) -> Dict[str, Any]:
|
|
257
|
+
"""Return a structured summary of the completed NAT run."""
|
|
258
|
+
elapsed = time.monotonic() - (self._start_time or 0)
|
|
259
|
+
suite_summary = self._suite.summary()
|
|
260
|
+
|
|
261
|
+
# Aggregate belief state across analyzers
|
|
262
|
+
avg_entropy = (
|
|
263
|
+
sum(a.beliefs.entropy() for a in self._analyzers) / len(self._analyzers)
|
|
264
|
+
if self._analyzers else 0.0
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Per-service failure counts from each executor
|
|
268
|
+
failure_counts: Dict[str, int] = {}
|
|
269
|
+
for exc in self._executors:
|
|
270
|
+
for svc, count in exc.beliefs.fault_likelihood.items():
|
|
271
|
+
pass # fault_likelihood is beliefs, not counts
|
|
272
|
+
# Use SUT call log for ground truth
|
|
273
|
+
failure_counts = self.sut.failure_counts() if hasattr(self.sut, "failure_counts") else {}
|
|
274
|
+
|
|
275
|
+
# Belief state from analyzers
|
|
276
|
+
combined_beliefs: Dict[str, float] = {}
|
|
277
|
+
for analyzer in self._analyzers:
|
|
278
|
+
for svc, val in analyzer.beliefs.fault_likelihood.items():
|
|
279
|
+
combined_beliefs[svc] = (combined_beliefs.get(svc, 0.0) + val)
|
|
280
|
+
if self._analyzers:
|
|
281
|
+
combined_beliefs = {k: v / len(self._analyzers) for k, v in combined_beliefs.items()}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
"suite": suite_summary,
|
|
285
|
+
"coordinator": {
|
|
286
|
+
"contracts_awarded": self._coordinator.contracts_awarded,
|
|
287
|
+
"contracts_completed": self._coordinator.contracts_completed,
|
|
288
|
+
},
|
|
289
|
+
"executors": [
|
|
290
|
+
{
|
|
291
|
+
"id": e.agent_id,
|
|
292
|
+
"executions": e.execution_count,
|
|
293
|
+
"avg_latency_ms": round(e.avg_latency_ms, 2),
|
|
294
|
+
}
|
|
295
|
+
for e in self._executors
|
|
296
|
+
],
|
|
297
|
+
"analyzers": [
|
|
298
|
+
{
|
|
299
|
+
"id": a.agent_id,
|
|
300
|
+
"total_results": a.total_results,
|
|
301
|
+
"belief_entropy": round(a.beliefs.entropy(), 4),
|
|
302
|
+
"top_risk_services": a.beliefs.top_k(3),
|
|
303
|
+
}
|
|
304
|
+
for a in self._analyzers
|
|
305
|
+
],
|
|
306
|
+
"beliefs": {
|
|
307
|
+
k: round(v, 4)
|
|
308
|
+
for k, v in sorted(
|
|
309
|
+
combined_beliefs.items(), key=lambda x: x[1], reverse=True
|
|
310
|
+
)
|
|
311
|
+
},
|
|
312
|
+
"avg_belief_entropy": round(avg_entropy, 4),
|
|
313
|
+
"actual_failure_counts": failure_counts,
|
|
314
|
+
"agent_count": (
|
|
315
|
+
len(self._planners) + len(self._executors)
|
|
316
|
+
+ len(self._analyzers) + 1
|
|
317
|
+
),
|
|
318
|
+
"elapsed_seconds": round(elapsed, 2),
|
|
319
|
+
"risk_report": self.get_risk_report(),
|
|
320
|
+
"anomaly_alerts": (
|
|
321
|
+
self.anomaly_detector.get_alerts_dict()
|
|
322
|
+
if self.anomaly_detector is not None
|
|
323
|
+
else []
|
|
324
|
+
),
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
def get_risk_report(self) -> List[Dict[str, Any]]:
|
|
328
|
+
"""Return the current per-endpoint risk report from the risk scorer."""
|
|
329
|
+
belief_states = [a.beliefs for a in self._analyzers]
|
|
330
|
+
return self._risk_scorer.get_risk_report(belief_states)
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def suite(self) -> TestSuite:
|
|
334
|
+
return self._suite
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def coordinator(self) -> CoordinatorAgent:
|
|
338
|
+
return self._coordinator
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def risk_scorer(self) -> RiskScorer:
|
|
342
|
+
return self._risk_scorer
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
"""Neural network primitives built on NumPy.
|
|
7
|
+
|
|
8
|
+
Provides a lightweight, dependency-free implementation of a fully-connected
|
|
9
|
+
feedforward network with backpropagation. Each agent in the framework owns
|
|
10
|
+
one or more of these networks to guide its decision-making.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from typing import List, Sequence
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Activation functions
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def relu(x: np.ndarray) -> np.ndarray:
|
|
24
|
+
return np.maximum(0.0, x)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def relu_deriv(x: np.ndarray) -> np.ndarray:
|
|
28
|
+
return (x > 0.0).astype(float)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sigmoid(x: np.ndarray) -> np.ndarray:
|
|
32
|
+
return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sigmoid_deriv(x: np.ndarray) -> np.ndarray:
|
|
36
|
+
s = sigmoid(x)
|
|
37
|
+
return s * (1.0 - s)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def softmax(x: np.ndarray) -> np.ndarray:
|
|
41
|
+
e = np.exp(x - np.max(x))
|
|
42
|
+
return e / e.sum()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Network layer
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
class Layer:
|
|
50
|
+
"""Single fully-connected layer with weight matrix and bias vector."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, n_in: int, n_out: int, activation: str = "relu") -> None:
|
|
53
|
+
self.activation = activation
|
|
54
|
+
# Xavier / Glorot initialisation
|
|
55
|
+
scale = np.sqrt(2.0 / n_in)
|
|
56
|
+
self.W: np.ndarray = np.random.randn(n_in, n_out) * scale
|
|
57
|
+
self.b: np.ndarray = np.zeros(n_out)
|
|
58
|
+
|
|
59
|
+
# Cache for backprop
|
|
60
|
+
self._x: np.ndarray | None = None
|
|
61
|
+
self._z: np.ndarray | None = None
|
|
62
|
+
|
|
63
|
+
# -- forward ----------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def forward(self, x: np.ndarray) -> np.ndarray:
|
|
66
|
+
self._x = x
|
|
67
|
+
self._z = x @ self.W + self.b
|
|
68
|
+
return self._activate(self._z)
|
|
69
|
+
|
|
70
|
+
def _activate(self, z: np.ndarray) -> np.ndarray:
|
|
71
|
+
if self.activation == "relu":
|
|
72
|
+
return relu(z)
|
|
73
|
+
if self.activation == "sigmoid":
|
|
74
|
+
return sigmoid(z)
|
|
75
|
+
if self.activation == "tanh":
|
|
76
|
+
return np.tanh(z)
|
|
77
|
+
if self.activation == "linear":
|
|
78
|
+
return z
|
|
79
|
+
raise ValueError(f"Unknown activation: {self.activation}")
|
|
80
|
+
|
|
81
|
+
def _activate_deriv(self, z: np.ndarray) -> np.ndarray:
|
|
82
|
+
if self.activation == "relu":
|
|
83
|
+
return relu_deriv(z)
|
|
84
|
+
if self.activation == "sigmoid":
|
|
85
|
+
return sigmoid_deriv(z)
|
|
86
|
+
if self.activation == "tanh":
|
|
87
|
+
return 1.0 - np.tanh(z) ** 2
|
|
88
|
+
if self.activation == "linear":
|
|
89
|
+
return np.ones_like(z)
|
|
90
|
+
raise ValueError(f"Unknown activation: {self.activation}")
|
|
91
|
+
|
|
92
|
+
# -- backward ---------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def backward(self, grad_out: np.ndarray) -> np.ndarray:
|
|
95
|
+
"""Return gradient w.r.t. input; cache weight/bias gradients."""
|
|
96
|
+
assert self._z is not None and self._x is not None
|
|
97
|
+
grad_z = grad_out * self._activate_deriv(self._z)
|
|
98
|
+
self._grad_W = self._x[:, None] * grad_z[None, :] # outer product
|
|
99
|
+
self._grad_b = grad_z
|
|
100
|
+
return grad_z @ self.W.T # propagate to previous layer
|
|
101
|
+
|
|
102
|
+
# -- parameter update -------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def apply_gradients(self, lr: float) -> None:
|
|
105
|
+
assert hasattr(self, "_grad_W"), "backward() must be called first"
|
|
106
|
+
self.W -= lr * self._grad_W
|
|
107
|
+
self.b -= lr * self._grad_b
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Fully-connected network
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
class NeuralNetwork:
|
|
115
|
+
"""Multi-layer perceptron with MSE loss and online gradient descent.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
layer_sizes:
|
|
120
|
+
Sequence of integers specifying the width of each layer,
|
|
121
|
+
e.g. ``[8, 16, 8, 4]`` creates three layers.
|
|
122
|
+
hidden_activation:
|
|
123
|
+
Activation used for all hidden layers. Output layer uses *linear*
|
|
124
|
+
activation so the network can regress arbitrary real values.
|
|
125
|
+
learning_rate:
|
|
126
|
+
Step size for stochastic gradient descent updates.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
layer_sizes: Sequence[int],
|
|
132
|
+
hidden_activation: str = "relu",
|
|
133
|
+
learning_rate: float = 0.01,
|
|
134
|
+
) -> None:
|
|
135
|
+
if len(layer_sizes) < 2:
|
|
136
|
+
raise ValueError("layer_sizes must contain at least two elements (in, out)")
|
|
137
|
+
self.lr = learning_rate
|
|
138
|
+
self.layers: List[Layer] = []
|
|
139
|
+
for i in range(len(layer_sizes) - 1):
|
|
140
|
+
act = hidden_activation if i < len(layer_sizes) - 2 else "linear"
|
|
141
|
+
self.layers.append(Layer(layer_sizes[i], layer_sizes[i + 1], activation=act))
|
|
142
|
+
|
|
143
|
+
# -- forward pass -----------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def predict(self, x: np.ndarray) -> np.ndarray:
|
|
146
|
+
"""Return network output for input vector *x*."""
|
|
147
|
+
out = x.astype(float)
|
|
148
|
+
for layer in self.layers:
|
|
149
|
+
out = layer.forward(out)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
# -- training step ----------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def train_step(self, x: np.ndarray, target: np.ndarray) -> float:
|
|
155
|
+
"""Forward pass, MSE loss, backprop, and SGD update.
|
|
156
|
+
|
|
157
|
+
Returns the scalar MSE loss value.
|
|
158
|
+
"""
|
|
159
|
+
output = self.predict(x)
|
|
160
|
+
loss = float(np.mean((output - target) ** 2))
|
|
161
|
+
|
|
162
|
+
# Gradient of MSE loss w.r.t. output
|
|
163
|
+
grad = 2.0 * (output - target) / len(output)
|
|
164
|
+
|
|
165
|
+
for layer in reversed(self.layers):
|
|
166
|
+
grad = layer.backward(grad)
|
|
167
|
+
|
|
168
|
+
for layer in self.layers:
|
|
169
|
+
layer.apply_gradients(self.lr)
|
|
170
|
+
|
|
171
|
+
return loss
|
|
172
|
+
|
|
173
|
+
# -- persistence helpers ----------------------------------------------
|
|
174
|
+
|
|
175
|
+
def get_weights(self) -> List[dict]:
|
|
176
|
+
"""Return a snapshot of all weight matrices and bias vectors."""
|
|
177
|
+
return [{"W": layer.W.copy(), "b": layer.b.copy()} for layer in self.layers]
|
|
178
|
+
|
|
179
|
+
def set_weights(self, weights: List[dict]) -> None:
|
|
180
|
+
"""Restore weights from a previous snapshot."""
|
|
181
|
+
for layer, wb in zip(self.layers, weights):
|
|
182
|
+
layer.W = wb["W"].copy()
|
|
183
|
+
layer.b = wb["b"].copy()
|