From b62856dccf10d5497dbb47024c688b1c8ddbb3be Mon Sep 17 00:00:00 2001 From: sonra44 Date: Wed, 20 Aug 2025 00:29:08 +0300 Subject: [PATCH] restore full readme --- .github/workflows/ci.yml | 12 + Dockerfile | 10 + README.md | 333 ++++++++++++++++++ docker-compose.yml | 30 ++ monitoring/dashboard.json | 4 + monitoring/prometheus.yml | 7 + ne_qiki/__init__.py | 1 + ne_qiki/api/__init__.py | 1 + ne_qiki/api/health_check.py | 17 + ne_qiki/benchmark/__init__.py | 1 + ne_qiki/benchmark/onnx_benchmark.py | 25 ++ ne_qiki/configs/config.example.yaml | 34 ++ ne_qiki/core/__init__.py | 1 + ne_qiki/core/calibration.py | 8 + ne_qiki/core/feature_extractor.py | 56 +++ ne_qiki/core/interfaces.py | 13 + ne_qiki/core/metrics.py | 8 + ne_qiki/core/nats_logger.py | 25 ++ ne_qiki/core/neural_engine_impl.py | 85 +++++ ne_qiki/core/proposal_evaluator.py | 10 + ne_qiki/core/safety.py | 44 +++ ne_qiki/datasets/__init__.py | 1 + ne_qiki/datasets/jsonl_dataset.py | 22 ++ ne_qiki/examples/__init__.py | 1 + ne_qiki/examples/mock_inference.py | 35 ++ ne_qiki/models/__init__.py | 1 + ne_qiki/models/ne_v1.py | 26 ++ ne_qiki/schemas/action_catalog.schema.json | 24 ++ ne_qiki/schemas/agent_context.schema.json | 19 + ne_qiki/schemas/safety.schema.yaml | 5 + ne_qiki/shared/__init__.py | 1 + ne_qiki/shared/models.py | 16 + requirements.txt | 9 + run_tests.py | 28 ++ tests/__init__.py | 1 + tests/test_dataset_generator.py | 20 ++ tests/test_feature_extractor.py | 34 ++ tests/test_global_logger.py | 60 ++++ tests/test_nats_logger.py | 32 ++ tests/test_ne_v1_contract.py | 53 +++ ...est_neural_engine_dashboard_integration.py | 94 +++++ tests/test_proposal_evaluator.py | 20 ++ tests/test_safety_shield.py | 40 +++ tests/test_terminal_dashboard.py | 110 ++++++ tools/__init__.py | 1 + tools/generate_dataset.py | 35 ++ tools/global_logger.py | 13 + tools/terminal_dashboard.py | 152 ++++++++ train_bc.py | 76 ++++ 49 files changed, 1654 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 monitoring/dashboard.json create mode 100644 monitoring/prometheus.yml create mode 100644 ne_qiki/__init__.py create mode 100644 ne_qiki/api/__init__.py create mode 100644 ne_qiki/api/health_check.py create mode 100644 ne_qiki/benchmark/__init__.py create mode 100644 ne_qiki/benchmark/onnx_benchmark.py create mode 100644 ne_qiki/configs/config.example.yaml create mode 100644 ne_qiki/core/__init__.py create mode 100644 ne_qiki/core/calibration.py create mode 100644 ne_qiki/core/feature_extractor.py create mode 100644 ne_qiki/core/interfaces.py create mode 100644 ne_qiki/core/metrics.py create mode 100644 ne_qiki/core/nats_logger.py create mode 100644 ne_qiki/core/neural_engine_impl.py create mode 100644 ne_qiki/core/proposal_evaluator.py create mode 100644 ne_qiki/core/safety.py create mode 100644 ne_qiki/datasets/__init__.py create mode 100644 ne_qiki/datasets/jsonl_dataset.py create mode 100644 ne_qiki/examples/__init__.py create mode 100644 ne_qiki/examples/mock_inference.py create mode 100644 ne_qiki/models/__init__.py create mode 100644 ne_qiki/models/ne_v1.py create mode 100644 ne_qiki/schemas/action_catalog.schema.json create mode 100644 ne_qiki/schemas/agent_context.schema.json create mode 100644 ne_qiki/schemas/safety.schema.yaml create mode 100644 ne_qiki/shared/__init__.py create mode 100644 ne_qiki/shared/models.py create mode 100644 requirements.txt create mode 100644 run_tests.py create mode 100644 tests/__init__.py create mode 100644 tests/test_dataset_generator.py create mode 100644 tests/test_feature_extractor.py create mode 100644 tests/test_global_logger.py create mode 100644 tests/test_nats_logger.py create mode 100644 tests/test_ne_v1_contract.py create mode 100644 tests/test_neural_engine_dashboard_integration.py create mode 100644 tests/test_proposal_evaluator.py create mode 100644 tests/test_safety_shield.py create mode 100644 tests/test_terminal_dashboard.py create mode 100644 tools/__init__.py create mode 100644 tools/generate_dataset.py create mode 100644 tools/global_logger.py create mode 100644 tools/terminal_dashboard.py create mode 100644 train_bc.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8b0905e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,12 @@ +name: CI +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - run: pip install -r requirements.txt + - run: python run_tests.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ef30c4d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.10-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "examples/mock_inference.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4267a0 --- /dev/null +++ b/README.md @@ -0,0 +1,333 @@ +# 📘 QIKI Neural Engine - Полная документация + +## 📋 Описание + +QIKI Neural Engine - это компактный, безопасный и CPU-оптимизированный нейросетевой движок для интеграции в агентную систему QIKI. Реализует генерацию "предложений" на основе анализа контекста агента и интегрируется в цикл принятия решений через интерфейс `INeuralEngine`. + +## 🗂️ Структура проекта + +``` +ne_qiki/ +├── core/ # Ядро движка +│ ├── interfaces.py # Интерфейс INeuralEngine +│ ├── feature_extractor.py # Векторизация контекста +│ ├── neural_engine_impl.py # Реализация NeuralEngineV1 +│ ├── proposal_evaluator.py # Оценка и фильтрация предложений +│ ├── safety.py # Безопасность и анти-флаппинг +│ ├── calibration.py # Температурная калибровка +│ ├── metrics.py # Prometheus-метрики +│ ├── nats_logger.py # NATS-логгер +│ └── __init__.py +├── models/ # Нейросетевые модели +│ ├── ne_v1.py # GRU+MLP модель +│ └── __init__.py +├── shared/ # Общие модели данных +│ ├── models.py # ActuatorCommand, Proposal +│ └── __init__.py +├── configs/ # Конфигурации +│ └── config.example.yaml # Конфигурация +├── schemas/ # JSON-схемы +│ ├── agent_context.schema.json # Схема контекста +│ ├── action_catalog.schema.json # Схема действий +│ └── safety.schema.yaml # Схема безопасности +├── api/ # API +│ └── health_check.py # Health-check API +├── benchmark/ # Бенчмарки +│ └── onnx_benchmark.py # Бенчмарк ONNX +├── datasets/ # Работа с датасетами +│ └── jsonl_dataset.py # Загрузчик JSONL +├── examples/ # Примеры использования +│ └── mock_inference.py # Пример инференса +├── tests/ # Юнит-тесты +│ ├── test_ne_v1_contract.py # Контракт NeuralEngine +│ ├── test_feature_extractor.py # Тест векторизатора +│ ├── test_safety_shield.py # Тест безопасности +│ ├── test_proposal_evaluator.py # Тест оценщика +│ ├── test_nats_logger.py # Тест NATS-логгера +│ ├── test_dataset_generator.py # Тест генератора датасета +│ ├── test_terminal_dashboard.py # Тест терминального dashboard +│ ├── test_neural_engine_dashboard_integration.py # Интеграционный тест +│ ├── test_global_logger.py # Тест глобального логгера +│ └── __init__.py +├── tools/ # Инструменты +│ ├── terminal_dashboard.py # Терминальный dashboard +│ ├── global_logger.py # Глобальный логгер +│ ├── generate_dataset.py # Генератор датасета +│ └── __init__.py +├── monitoring/ # Мониторинг +│ ├── prometheus.yml # Конфиг Prometheus +│ └── dashboard.json # Grafana дашборд +├── train_bc.py # Обучение и экспорт ONNX +├── run_tests.py # Единый тест-раннер +├── Dockerfile # Docker-образ +├── docker-compose.yml # Docker Compose +├── requirements.txt # Зависимости +└── .github/ + └── workflows/ + └── ci.yml # CI Pipeline +``` + +## 🚀 Быстрый старт + +### Установка зависимостей + +```bash +pip install -r requirements.txt +``` + +### Запуск всех компонентов + +```bash +docker-compose up -d +``` + +### Пример использования + +```bash +python examples/mock_inference.py +``` + +### Запуск тестов + +```bash +python run_tests.py +``` + +### Терминальный dashboard + +```bash +python tools/terminal_dashboard.py +``` + +## 🧪 Архитектура + +### Основные компоненты + +| Компонент | Назначение | +|----------|------------| +| `INeuralEngine` | Интерфейс для интеграции в QIKI | +| `NeuralEngineV1` | Реализация: векторизация → модель → предложения | +| `NE_v1` | GRU(2×64) + MLP головы | +| `FeatureExtractor` | Преобразует `AgentContext` в тензор | +| `SafetyShield` | Проверяет безопасность и предотвращает флаппинг | +| `ProposalEvaluator` | Фильтрует и сортирует предложения | + +### Поток данных + +``` +AgentContext → FeatureExtractor → NE_v1 (GRU+MLP) → Calibration → Proposals → +SafetyShield → ProposalEvaluator → Decision +``` + +## 📦 Конфигурация + +Файл: `configs/config.example.yaml` + +```yaml +window: 16 +in_dim: 32 +num_classes: 6 +param_dim: 4 +topk: 3 +min_confidence: 0.55 +time_budget_ms: 8 +calibration: + temperature: 1.2 +action_catalog: + actions: + - name: HOLD_POSITION + params: + duration: [0.1, 5.0] + - name: COOLING_BOOST + params: + intensity: [0.1, 1.0] + - name: THROTTLE_DOWN + params: + level: [0.0, 1.0] + - name: THROTTLE_UP + params: + level: [0.0, 1.0] + - name: ROTATE_LEFT + params: + angle: [0.0, 3.14] + - name: ROTATE_RIGHT + params: + angle: [0.0, 3.14] +safety: + fsm_invariants: + - ERROR_STATE + bios_invariants: + - bios_ok +``` + +## 🔐 Безопасность + +### Реализованные механизмы + +- **FSM-инварианты**: нельзя действовать при `ERROR_STATE` +- **BIOS-инварианты**: проверка аппаратных параметров +- **Анти-флаппинг**: защита от частых переключений действий +- **Маскирование действий**: модель не может предложить запрещённое действие +- **Валидация параметров**: проверка диапазонов параметров действий + +### SafetyShield + +Проверяет: +- Состояние FSM +- Статус BIOS +- Корректность параметров действий +- Частоту повторения действий (анти-флаппинг) + +## 📊 Мониторинг + +### Prometheus-метрики + +| Метрика | Описание | +|--------|----------| +| `ne_inference_total` | Количество вызовов инференса | +| `ne_inference_duration_seconds` | Латентность инференса | +| `ne_active_proposals` | Количество активных предложений | +| `ne_avg_confidence` | Средняя уверенность предложений | +| `ne_safety_blocks_total` | Количество блокировок SafetyShield | +| `ne_degradation_to_rule` | Количество деградаций в RuleEngine | + +### Health-check API + +```bash +curl http://localhost:5000/health +``` + +### Терминальный dashboard + +Интерактивный терминальный интерфейс с реалтайм метриками и логами: + +```bash +python tools/terminal_dashboard.py +``` + +Горячие клавиши: +- `q` - Выход +- `Ctrl+C` - Аварийный выход + +## 📡 Логирование + +### NATS-логгер + +Все предложения логируются в топик `qiki.neural.proposals` + +### Терминальное логирование + +Реалтайм вывод логов в терминальном dashboard + +## 🧠 Обучение модели + +### Скрипт обучения + +```bash +python train_bc.py +``` + +Включает: +- Обучение методом Behavior Cloning +- Экспорт в ONNX +- INT8-квантизация +- Бенчмарк производительности + +### Генератор датасета + +```bash +python tools/generate_dataset.py +``` + +Генерирует датасет в формате JSONL для обучения + +## 🧪 Тестирование + +### Запуск всех тестов + +```bash +python run_tests.py +``` + +### Покрытие тестами + +| Компонент | Тест | +|----------|------| +| `NeuralEngine` | `test_ne_v1_contract.py` | +| `FeatureExtractor` | `test_feature_extractor.py` | +| `SafetyShield` | `test_safety_shield.py` | +| `ProposalEvaluator` | `test_proposal_evaluator.py` | +| `NATSLogger` | `test_nats_logger.py` | +| `DatasetGenerator` | `test_dataset_generator.py` | +| `TerminalDashboard` | `test_terminal_dashboard.py` | + +## 🐳 Docker + +### Docker Compose + +Запуск всей системы: + +```bash +docker-compose up -d +``` + +Включает: +- QIKI Neural Engine +- Prometheus +- Grafana +- NATS (опционально) + +### Отдельный запуск + +```bash +docker build -t qiki-ne . +docker run qiki-ne +``` + +## 🧾 CI/CD + +### GitHub Actions + +Файл: `.github/workflows/ci.yml` + +Автоматический запуск: +- Тестов при каждом пуше +- Проверки контрактов +- Валидации тайм-бюджета + +## 🧩 Интеграция с QIKI + +### Шаги интеграции + +1. Реализуйте `IDataProvider` для передачи `AgentContext` +2. Подключите `NeuralEngineV1` как `INeuralEngine` +3. Настройте конфигурацию в `configs/config.yaml` +4. Запустите систему через Docker Compose + +### Контракт INeuralEngine + +```python +class INeuralEngine(ABC): + def generate_proposals(self, context: Any) -> List[Proposal]: + pass +``` + +## 📈 План развития + +### Уровень 3 (долгосрочно) + +- Transformer-модель вместо GRU +- RL-обучение (PPO, Offline RL) +- ONNX Server как отдельный микросервис +- Human-in-the-loop обучение +- Асинхронный инференс +- Multi-agent координация + +## 📄 Лицензия + +MIT + +## 📞 Контакты + +- Версия: 2.0 +- Дата: 2025-04-05 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be70f90 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + qiki-ne: + build: . + ports: + - "5000:5000" + depends_on: + - prometheus + - grafana + environment: + - FLASK_ENV=production + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + + grafana: + image: grafana/grafana-enterprise + ports: + - "3000:3000" + depends_on: + - prometheus + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin diff --git a/monitoring/dashboard.json b/monitoring/dashboard.json new file mode 100644 index 0000000..f1f504e --- /dev/null +++ b/monitoring/dashboard.json @@ -0,0 +1,4 @@ +{ + "title": "QIKI Neural Engine Dashboard", + "panels": [] +} diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 0000000..916f1a9 --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'qiki-ne' + static_configs: + - targets: ['qiki-ne:5000'] diff --git a/ne_qiki/__init__.py b/ne_qiki/__init__.py new file mode 100644 index 0000000..2a9550a --- /dev/null +++ b/ne_qiki/__init__.py @@ -0,0 +1 @@ +# QIKI Neural Engine package diff --git a/ne_qiki/api/__init__.py b/ne_qiki/api/__init__.py new file mode 100644 index 0000000..28b07ef --- /dev/null +++ b/ne_qiki/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/ne_qiki/api/health_check.py b/ne_qiki/api/health_check.py new file mode 100644 index 0000000..161bdbf --- /dev/null +++ b/ne_qiki/api/health_check.py @@ -0,0 +1,17 @@ +from flask import Flask, jsonify +import torch + +app = Flask(__name__) + +@app.route("/health", methods=["GET"]) +def health(): + try: + # Проверка доступности модели + x = torch.randn(1, 16, 32) + mask = torch.ones(1, 6).bool() + return jsonify({"status": "ok", "model": "loaded"}), 200 + except Exception as e: + return jsonify({"status": "error", "details": str(e)}), 500 + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/ne_qiki/benchmark/__init__.py b/ne_qiki/benchmark/__init__.py new file mode 100644 index 0000000..ec328f4 --- /dev/null +++ b/ne_qiki/benchmark/__init__.py @@ -0,0 +1 @@ +# Benchmark package diff --git a/ne_qiki/benchmark/onnx_benchmark.py b/ne_qiki/benchmark/onnx_benchmark.py new file mode 100644 index 0000000..879823b --- /dev/null +++ b/ne_qiki/benchmark/onnx_benchmark.py @@ -0,0 +1,25 @@ +import time +import torch +import onnxruntime as ort + +def benchmark_onnx(model_path: str, iterations: int = 100): + session = ort.InferenceSession(model_path) + dummy_input = torch.randn(1, 16, 32).numpy() + dummy_mask = torch.ones(1, 6).bool().numpy() + + # Warmup + for _ in range(10): + session.run(None, {"input": dummy_input, "mask": dummy_mask}) + + # Benchmark + start = time.time() + for _ in range(iterations): + session.run(None, {"input": dummy_input, "mask": dummy_mask}) + elapsed = time.time() - start + + avg_ms = (elapsed / iterations) * 1000 + print(f"ONNX Inference Avg Latency: {avg_ms:.2f} ms") + return avg_ms + +if __name__ == "__main__": + benchmark_onnx("ne_v1_int8.onnx") diff --git a/ne_qiki/configs/config.example.yaml b/ne_qiki/configs/config.example.yaml new file mode 100644 index 0000000..2d8db04 --- /dev/null +++ b/ne_qiki/configs/config.example.yaml @@ -0,0 +1,34 @@ +window: 16 +in_dim: 32 +num_classes: 6 +param_dim: 4 +topk: 3 +min_confidence: 0.55 +time_budget_ms: 8 +calibration: + temperature: 1.2 +action_catalog: + actions: + - name: HOLD_POSITION + params: + duration: [0.1, 5.0] + - name: COOLING_BOOST + params: + intensity: [0.1, 1.0] + - name: THROTTLE_DOWN + params: + level: [0.0, 1.0] + - name: THROTTLE_UP + params: + level: [0.0, 1.0] + - name: ROTATE_LEFT + params: + angle: [0.0, 3.14] + - name: ROTATE_RIGHT + params: + angle: [0.0, 3.14] +safety: + fsm_invariants: + - ERROR_STATE + bios_invariants: + - bios_ok diff --git a/ne_qiki/core/__init__.py b/ne_qiki/core/__init__.py new file mode 100644 index 0000000..d145435 --- /dev/null +++ b/ne_qiki/core/__init__.py @@ -0,0 +1 @@ +# Core components package diff --git a/ne_qiki/core/calibration.py b/ne_qiki/core/calibration.py new file mode 100644 index 0000000..36432a2 --- /dev/null +++ b/ne_qiki/core/calibration.py @@ -0,0 +1,8 @@ +import torch + +class Calibration: + def __init__(self, temperature: float = 1.0): + self.temperature = temperature + + def calibrate(self, logits: torch.Tensor) -> torch.Tensor: + return torch.softmax(logits / self.temperature, dim=-1) diff --git a/ne_qiki/core/feature_extractor.py b/ne_qiki/core/feature_extractor.py new file mode 100644 index 0000000..00a9b1b --- /dev/null +++ b/ne_qiki/core/feature_extractor.py @@ -0,0 +1,56 @@ +import torch +import numpy as np +from typing import Tuple + +class FeatureExtractor: + def __init__(self, window: int = 16, in_dim: int = 32): + self.window = window + self.in_dim = in_dim + + def extract(self, context) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Извлекает признаки из AgentContext и возвращает тензор и маску действий. + """ + features = [] + + # FSM one-hot (предполагаем 4 состояния) + fsm_map = {"BOOTING": 0, "IDLE": 1, "ACTIVE": 2, "ERROR_STATE": 3} + fsm_vec = np.zeros(4) + fsm_vec[fsm_map.get(context.fsm_state, 3)] = 1.0 + features.extend(fsm_vec) + + # BIOS статусы: температура, питание, utilization + bios = context.bios_status + features.extend([ + min(1.0, max(0.0, getattr(bios, 'temperature', 0) / 100.0)), + min(1.0, max(0.0, getattr(bios, 'power_draw', 0) / 100.0)), + min(1.0, max(0.0, getattr(bios, 'utilization', 0) / 100.0)) + ]) + + # Sensor data + sensor = context.sensor_data or {} + features.extend([ + min(1.0, max(0.0, sensor.get("distance", 0.0) / 10.0)), + min(1.0, max(-1.0, sensor.get("velocity", 0.0) / 5.0)), + min(1.0, max(-1.0, sensor.get("azimuth", 0.0) / 3.14)), + min(1.0, max(0.0, sensor.get("hazard_score", 0.0))) + ]) + + # Action history + hist = sensor.get("action_history", []) + hist_vec = np.zeros(5) + for i, act in enumerate(hist[-5:]): + hist_vec[i] = min(1.0, max(0.0, act / 10.0)) + features.extend(hist_vec) + + # Паддинг до in_dim + while len(features) < self.in_dim: + features.append(0.0) + + # Тензор окна (T, in_dim) + tensor = torch.tensor(features[:self.in_dim], dtype=torch.float32).unsqueeze(0).repeat(self.window, 1).unsqueeze(0) + + # Пример маски (допустим, первые 4 действия разрешены) + mask = torch.tensor([True, True, True, True, False, False], dtype=torch.bool).unsqueeze(0) + + return tensor, mask diff --git a/ne_qiki/core/interfaces.py b/ne_qiki/core/interfaces.py new file mode 100644 index 0000000..2bf9acb --- /dev/null +++ b/ne_qiki/core/interfaces.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from typing import List, Any +from shared.models import Proposal + +class INeuralEngine(ABC): + """ + Abstract interface for the Neural Engine, responsible for generating proposals based on ML models. + """ + + @abstractmethod + def generate_proposals(self, context: Any) -> List[Proposal]: + """Generates a list of proposals based on the current agent context using ML models.""" + pass diff --git a/ne_qiki/core/metrics.py b/ne_qiki/core/metrics.py new file mode 100644 index 0000000..f2e1efa --- /dev/null +++ b/ne_qiki/core/metrics.py @@ -0,0 +1,8 @@ +from prometheus_client import Counter, Histogram, Gauge + +INFERENCE_COUNT = Counter('ne_inference_total', 'Total inference calls') +INFERENCE_LATENCY = Histogram('ne_inference_duration_seconds', 'Inference latency') +ACTIVE_PROPOSALS = Gauge('ne_active_proposals', 'Number of active proposals generated') +AVG_CONFIDENCE = Gauge('ne_avg_confidence', 'Average confidence of proposals') +SAFETY_BLOCKS = Counter('ne_safety_blocks_total', 'Total safety blocks') +DEGRADATIONS = Counter('ne_degradation_to_rule', 'Total degradations to rule engine') diff --git a/ne_qiki/core/nats_logger.py b/ne_qiki/core/nats_logger.py new file mode 100644 index 0000000..eee56df --- /dev/null +++ b/ne_qiki/core/nats_logger.py @@ -0,0 +1,25 @@ +import json +import asyncio +import nats + +class NATSLogger: + def __init__(self, nats_url="nats://localhost:4222"): + self.nats_url = nats_url + self.nc = None + + async def connect(self): + try: + self.nc = await nats.connect(self.nats_url) + except Exception as e: + print(f"[NATS] Connection failed: {e}") + + async def log_proposal(self, proposal_data): + if self.nc: + try: + await self.nc.publish("qiki.neural.proposals", json.dumps(proposal_data).encode()) + except Exception as e: + print(f"[NATS] Publish failed: {e}") + + async def close(self): + if self.nc: + await self.nc.close() diff --git a/ne_qiki/core/neural_engine_impl.py b/ne_qiki/core/neural_engine_impl.py new file mode 100644 index 0000000..023cd9f --- /dev/null +++ b/ne_qiki/core/neural_engine_impl.py @@ -0,0 +1,85 @@ +import time +import json +from typing import List +import asyncio +from core.interfaces import INeuralEngine +from shared.models import Proposal, ActuatorCommand +from models.ne_v1 import NE_v1 +from core.feature_extractor import FeatureExtractor +from core.calibration import Calibration +from core.safety import SafetyShield +from core.metrics import INFERENCE_COUNT, INFERENCE_LATENCY +from core.nats_logger import NATSLogger +import torch + +class NeuralEngineV1(INeuralEngine): + def __init__(self, config): + self.model = NE_v1(config['in_dim'], 64, config['num_classes'], config['param_dim']) + self.extractor = FeatureExtractor(config['window'], config['in_dim']) + self.calibrator = Calibration(config['calibration']['temperature']) + self.safety = SafetyShield(config['action_catalog']) + self.config = config + self.nats_logger = NATSLogger() + asyncio.create_task(self.nats_logger.connect()) + + def generate_proposals(self, context) -> List[Proposal]: + start = time.time() + INFERENCE_COUNT.inc() + try: + tensor, mask = self.extractor.extract(context) + logits, priority, params = self.model(tensor, mask) + probs = self.calibrator.calibrate(logits) + + proposals = [] + top_k = torch.topk(probs, min(self.config['topk'], probs.size(-1)), dim=-1) + for i in range(top_k.indices.size(1)): + idx = top_k.indices[0, i].item() + conf = top_k.values[0, i].item() + if conf < self.config['min_confidence']: + continue + action_meta = self.config['action_catalog']['actions'][idx] + action_name = action_meta['name'] + actuator = ActuatorCommand(action_name, {}) + proposal = Proposal( + proposal_id=f"ne_{idx}", + source_module_id="NeuralEngineV1", + confidence=conf, + priority=priority.item(), + justification=f"Predicted by NE_v1 for {action_name}", + proposed_actions=[actuator] + ) + proposals.append(proposal) + + # Логирование + self._log_proposals(proposals, context) + + proposals = self.safety.validate(proposals, context.fsm_state, context.bios_status.ok) + except Exception as e: + print(f"[NE] Exception: {e}") + proposals = [] + + elapsed = (time.time() - start) * 1000 + INFERENCE_LATENCY.observe(elapsed / 1000.0) + if elapsed > self.config['time_budget_ms']: + print(f"[NE] Timeout: {elapsed:.2f} ms") + return [] + return proposals + + def _log_proposals(self, proposals, context): + log_data = { + "timestamp": time.time(), + "fsm_state": context.fsm_state, + "bios_ok": context.bios_status.ok, + "proposals": [ + { + "id": p.proposal_id, + "confidence": p.confidence, + "priority": p.priority, + "action": p.proposed_actions[0].name if p.proposed_actions else None + } + for p in proposals + ] + } + print(json.dumps(log_data)) + # NATS logging + asyncio.create_task(self.nats_logger.log_proposal(log_data)) diff --git a/ne_qiki/core/proposal_evaluator.py b/ne_qiki/core/proposal_evaluator.py new file mode 100644 index 0000000..368c752 --- /dev/null +++ b/ne_qiki/core/proposal_evaluator.py @@ -0,0 +1,10 @@ +from shared.models import Proposal +from typing import List + +class ProposalEvaluator: + def evaluate(self, proposals: List[Proposal]) -> List[Proposal]: + # Фильтр по confidence + filtered = [p for p in proposals if p.confidence >= 0.5] + # Сортировка по priority + confidence + filtered.sort(key=lambda x: (x.priority, x.confidence), reverse=True) + return filtered[:3] diff --git a/ne_qiki/core/safety.py b/ne_qiki/core/safety.py new file mode 100644 index 0000000..1c339f1 --- /dev/null +++ b/ne_qiki/core/safety.py @@ -0,0 +1,44 @@ +from shared.models import Proposal, ActuatorCommand +from collections import deque + +class SafetyShield: + def __init__(self, action_catalog, max_actions_per_tick=3, flap_window=5): + self.action_catalog = {a['name']: a for a in action_catalog['actions']} + self.max_actions = max_actions_per_tick + self.flap_window = flap_window + self.action_history = deque(maxlen=flap_window) + + def validate(self, proposals: list, fsm_state: str, bios_ok: bool) -> list: + if fsm_state == "ERROR_STATE" or not bios_ok: + return [] + + filtered = [] + for p in proposals: + if len(p.proposed_actions) > self.max_actions: + continue + valid = True + for cmd in p.proposed_actions: + if not self._validate_action(cmd): + valid = False + break + if valid and not self._is_flapping(p): + filtered.append(p) + self.action_history.append(p.proposed_actions[0].name if p.proposed_actions else None) + return filtered + + def _validate_action(self, cmd: ActuatorCommand) -> bool: + meta = self.action_catalog.get(cmd.name) + if not meta: + return False + for k, v in cmd.params.items(): + vmin, vmax = meta['params'].get(k, (None, None)) + if vmin is not None and not (vmin <= v <= vmax): + return False + return True + + def _is_flapping(self, proposal): + if not self.action_history: + return False + last_action = self.action_history[-1] + current_action = proposal.proposed_actions[0].name if proposal.proposed_actions else None + return last_action == current_action and len([a for a in self.action_history if a == current_action]) >= 2 diff --git a/ne_qiki/datasets/__init__.py b/ne_qiki/datasets/__init__.py new file mode 100644 index 0000000..5ad4f53 --- /dev/null +++ b/ne_qiki/datasets/__init__.py @@ -0,0 +1 @@ +# Datasets package diff --git a/ne_qiki/datasets/jsonl_dataset.py b/ne_qiki/datasets/jsonl_dataset.py new file mode 100644 index 0000000..01c1e5e --- /dev/null +++ b/ne_qiki/datasets/jsonl_dataset.py @@ -0,0 +1,22 @@ +import json +import torch +from torch.utils.data import Dataset + +class JSONLDataset(Dataset): + def __init__(self, path: str): + self.data = [] + with open(path, 'r') as f: + for line in f: + self.data.append(json.loads(line)) + + def __len__(self): + return len(self.data) + + def __getitem__(self, idx): + item = self.data[idx] + features = torch.tensor(item['features'], dtype=torch.float32) + label = torch.tensor(item['label'], dtype=torch.long) + priority = torch.tensor(item['priority'], dtype=torch.float32) + params = torch.tensor(item['params'], dtype=torch.float32) + mask = torch.tensor(item['action_mask'], dtype=torch.bool) + return features, label, priority, params, mask diff --git a/ne_qiki/examples/__init__.py b/ne_qiki/examples/__init__.py new file mode 100644 index 0000000..a5c80fb --- /dev/null +++ b/ne_qiki/examples/__init__.py @@ -0,0 +1 @@ +# Examples package diff --git a/ne_qiki/examples/mock_inference.py b/ne_qiki/examples/mock_inference.py new file mode 100644 index 0000000..14d81c8 --- /dev/null +++ b/ne_qiki/examples/mock_inference.py @@ -0,0 +1,35 @@ +from core.neural_engine_impl import NeuralEngineV1 +from dataclasses import dataclass + +@dataclass +class MockBiosStatus: + ok: bool + +@dataclass +class MockContext: + bios_status: MockBiosStatus + fsm_state: str + +if __name__ == "__main__": + config = { + "window": 16, + "in_dim": 32, + "num_classes": 6, + "param_dim": 4, + "topk": 3, + "min_confidence": 0.55, + "time_budget_ms": 8, + "calibration": {"temperature": 1.2}, + "action_catalog": { + "actions": [ + {"name": "HOLD_POSITION", "params": {}}, + {"name": "COOLING_BOOST", "params": {}}, + ] + } + } + + engine = NeuralEngineV1(config) + context = MockContext(bios_status=MockBiosStatus(ok=True), fsm_state="ACTIVE") + proposals = engine.generate_proposals(context) + for p in proposals: + print(p) diff --git a/ne_qiki/models/__init__.py b/ne_qiki/models/__init__.py new file mode 100644 index 0000000..f3d9f4b --- /dev/null +++ b/ne_qiki/models/__init__.py @@ -0,0 +1 @@ +# Models package diff --git a/ne_qiki/models/ne_v1.py b/ne_qiki/models/ne_v1.py new file mode 100644 index 0000000..5290281 --- /dev/null +++ b/ne_qiki/models/ne_v1.py @@ -0,0 +1,26 @@ +import torch +import torch.nn as nn + +class NE_v1(nn.Module): + def __init__(self, input_dim: int, hidden_dim: int, num_classes: int, param_dim: int): + super().__init__() + self.gru = nn.GRU(input_dim, hidden_dim, batch_first=True, num_layers=2) + self.class_head = nn.Linear(hidden_dim, num_classes) + self.priority_head = nn.Sequential( + nn.Linear(hidden_dim, 1), + nn.Sigmoid() + ) + self.param_head = nn.Linear(hidden_dim, param_dim) + + def forward(self, x, action_mask=None): + out, _ = self.gru(x) + features = out[:, -1, :] # последний таймстеп + + logits = self.class_head(features) + if action_mask is not None: + logits = logits.masked_fill(~action_mask.bool(), float('-inf')) + + priority = self.priority_head(features).squeeze(-1) + params = self.param_head(features) + + return logits, priority, params diff --git a/ne_qiki/schemas/action_catalog.schema.json b/ne_qiki/schemas/action_catalog.schema.json new file mode 100644 index 0000000..e3b9310 --- /dev/null +++ b/ne_qiki/schemas/action_catalog.schema.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "params": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["name", "params"] + } + } + } +} diff --git a/ne_qiki/schemas/agent_context.schema.json b/ne_qiki/schemas/agent_context.schema.json new file mode 100644 index 0000000..dcf9363 --- /dev/null +++ b/ne_qiki/schemas/agent_context.schema.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "bios_status": { + "type": "object", + "properties": { + "ok": {"type": "boolean"} + }, + "required": ["ok"] + }, + "fsm_state": { + "type": "string" + }, + "sensor_data": { + "type": "object" + } + }, + "required": ["bios_status", "fsm_state"] +} diff --git a/ne_qiki/schemas/safety.schema.yaml b/ne_qiki/schemas/safety.schema.yaml new file mode 100644 index 0000000..c50595f --- /dev/null +++ b/ne_qiki/schemas/safety.schema.yaml @@ -0,0 +1,5 @@ +fsm_invariants: + - ERROR_STATE +bios_invariants: + - bios_ok +max_actions_per_tick: 3 diff --git a/ne_qiki/shared/__init__.py b/ne_qiki/shared/__init__.py new file mode 100644 index 0000000..5a12da0 --- /dev/null +++ b/ne_qiki/shared/__init__.py @@ -0,0 +1 @@ +# Shared models package diff --git a/ne_qiki/shared/models.py b/ne_qiki/shared/models.py new file mode 100644 index 0000000..7aaf443 --- /dev/null +++ b/ne_qiki/shared/models.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Dict, List, Optional + +@dataclass +class ActuatorCommand: + name: str + params: Dict[str, float] + +@dataclass +class Proposal: + proposal_id: str + source_module_id: str + confidence: float + priority: float + justification: str + proposed_actions: List[ActuatorCommand] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..664a466 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +torch>=1.13 +onnxruntime>=1.14 +pyyaml +numpy +flask +prometheus-client +nats-py +prompt_toolkit>=3.0 +requests diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..b21db86 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,28 @@ +import unittest +import sys +import os + +# Добавляем пути +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'ne_qiki')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'tools')) + +def run_all_tests(): + """Запуск всех тестов""" + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Добавляем тесты из разных директорий + suite.addTests(loader.discover('tests', pattern='test_*.py')) + + # Создаем runner + runner = unittest.TextTestRunner(verbosity=2) + + # Запускаем тесты + result = runner.run(suite) + + # Возвращаем код выхода + return 0 if result.wasSuccessful() else 1 + +if __name__ == "__main__": + exit_code = run_all_tests() + sys.exit(exit_code) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/test_dataset_generator.py b/tests/test_dataset_generator.py new file mode 100644 index 0000000..9f3527d --- /dev/null +++ b/tests/test_dataset_generator.py @@ -0,0 +1,20 @@ +import unittest +import os +from tools.generate_dataset import generate_dataset + +class TestDatasetGenerator(unittest.TestCase): + def test_generate_dataset(self): + path = "test_dataset.jsonl" + generate_dataset(path, size=10) + self.assertTrue(os.path.exists(path)) + + # Проверим содержимое + with open(path, 'r') as f: + lines = f.readlines() + self.assertEqual(len(lines), 10) + + # Удалим тестовый файл + os.remove(path) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_feature_extractor.py b/tests/test_feature_extractor.py new file mode 100644 index 0000000..0762ecf --- /dev/null +++ b/tests/test_feature_extractor.py @@ -0,0 +1,34 @@ +import unittest +import torch +from core.feature_extractor import FeatureExtractor +from dataclasses import dataclass + +@dataclass +class MockBiosStatus: + temperature: float = 50.0 + power_draw: float = 50.0 + utilization: float = 50.0 + ok: bool = True + +@dataclass +class MockContext: + bios_status: MockBiosStatus + fsm_state: str + sensor_data: dict = None + +class TestFeatureExtractor(unittest.TestCase): + def setUp(self): + self.extractor = FeatureExtractor(window=16, in_dim=32) + + def test_extract_shape(self): + context = MockContext( + bios_status=MockBiosStatus(), + fsm_state="ACTIVE", + sensor_data={"distance": 5.0, "velocity": 1.0, "azimuth": 0.5, "hazard_score": 0.3} + ) + tensor, mask = self.extractor.extract(context) + self.assertEqual(tensor.shape, (1, 16, 32)) + self.assertEqual(mask.shape, (1, 6)) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_global_logger.py b/tests/test_global_logger.py new file mode 100644 index 0000000..2f08edf --- /dev/null +++ b/tests/test_global_logger.py @@ -0,0 +1,60 @@ +import unittest +import sys +import os + +# Добавляем путь к tools +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools')) + +class TestGlobalLogger(unittest.TestCase): + def test_global_logger_singleton(self): + from tools.global_logger import GLOBAL_LOGGER + + # Проверяем, что это синглтон + from tools.global_logger import GlobalLogger + logger2 = GlobalLogger() + + # GLOBAL_LOGGER должен быть одним и тем же экземпляром + self.assertIsNotNone(GLOBAL_LOGGER) + + def test_set_dashboard(self): + from tools.global_logger import GLOBAL_LOGGER + + # Создаем мок dashboard + mock_dashboard = unittest.mock.MagicMock() + + # Устанавливаем dashboard + GLOBAL_LOGGER.set_dashboard(mock_dashboard) + + # Проверяем, что dashboard установлен + self.assertEqual(GLOBAL_LOGGER.dashboard, mock_dashboard) + + def test_add_log_without_dashboard(self): + from tools.global_logger import GLOBAL_LOGGER + + # Сбрасываем dashboard + GLOBAL_LOGGER.dashboard = None + + # Должно работать без ошибок + try: + GLOBAL_LOGGER.add_log("Test message") + success = True + except: + success = False + + self.assertTrue(success) + + def test_add_log_with_dashboard(self): + from tools.global_logger import GLOBAL_LOGGER + + # Создаем мок dashboard + mock_dashboard = unittest.mock.MagicMock() + GLOBAL_LOGGER.set_dashboard(mock_dashboard) + + # Добавляем лог + GLOBAL_LOGGER.add_log("Test message") + + # Проверяем, что метод dashboard.add_log был вызван + mock_dashboard.add_log.assert_called_once_with("Test message") + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nats_logger.py b/tests/test_nats_logger.py new file mode 100644 index 0000000..aa33322 --- /dev/null +++ b/tests/test_nats_logger.py @@ -0,0 +1,32 @@ +import unittest +from unittest.mock import AsyncMock, patch +import asyncio +from core.nats_logger import NATSLogger + +class TestNATSLogger(unittest.TestCase): + def setUp(self): + self.logger = NATSLogger() + + @patch('core.nats_logger.nats.connect') + def test_connect(self, mock_connect): + mock_connect.return_value = AsyncMock() + async def run_test(): + await self.logger.connect() + mock_connect.assert_called_once() + + asyncio.run(run_test()) + + @patch('core.nats_logger.nats.connect') + def test_log_proposal(self, mock_connect): + mock_nc = AsyncMock() + mock_connect.return_value = mock_nc + + async def run_test(): + await self.logger.connect() + await self.logger.log_proposal({"test": "data"}) + mock_nc.publish.assert_called_once() + + asyncio.run(run_test()) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ne_v1_contract.py b/tests/test_ne_v1_contract.py new file mode 100644 index 0000000..cf96b90 --- /dev/null +++ b/tests/test_ne_v1_contract.py @@ -0,0 +1,53 @@ +import unittest +from core.neural_engine_impl import NeuralEngineV1 +from shared.models import Proposal +from dataclasses import dataclass +from typing import Dict, List + +@dataclass +class MockBiosStatus: + ok: bool = True + temperature: float = 50.0 + power_draw: float = 50.0 + utilization: float = 50.0 + +@dataclass +class MockContext: + bios_status: MockBiosStatus + fsm_state: str + sensor_data: Dict = None + +class TestNeuralEngineV1(unittest.TestCase): + def setUp(self): + self.config = { + "window": 16, + "in_dim": 32, + "num_classes": 6, + "param_dim": 4, + "topk": 3, + "min_confidence": 0.55, + "time_budget_ms": 8, + "calibration": {"temperature": 1.2}, + "action_catalog": { + "actions": [ + {"name": "HOLD_POSITION", "params": {}}, + {"name": "COOLING_BOOST", "params": {}}, + ] + } + } + self.engine = NeuralEngineV1(self.config) + + def test_generate_proposals(self): + context = MockContext(bios_status=MockBiosStatus(ok=True), fsm_state="ACTIVE") + proposals = self.engine.generate_proposals(context) + self.assertIsInstance(proposals, list) + if proposals: + self.assertIsInstance(proposals[0], Proposal) + + def test_error_state_returns_empty(self): + context = MockContext(bios_status=MockBiosStatus(ok=True), fsm_state="ERROR_STATE") + proposals = self.engine.generate_proposals(context) + self.assertEqual(proposals, []) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_neural_engine_dashboard_integration.py b/tests/test_neural_engine_dashboard_integration.py new file mode 100644 index 0000000..a735627 --- /dev/null +++ b/tests/test_neural_engine_dashboard_integration.py @@ -0,0 +1,94 @@ +import unittest +import sys +import os +from unittest.mock import patch, MagicMock + +# Добавляем пути +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'ne_qiki')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools')) + +class TestNeuralEngineDashboardIntegration(unittest.TestCase): + def setUp(self): + # Мокаем torch и модель + self.mock_torch = MagicMock() + self.mock_model = MagicMock() + + @patch('ne_qiki.core.neural_engine_impl.torch') + @patch('ne_qiki.core.neural_engine_impl.NE_v1') + def test_neural_engine_logs_to_dashboard(self, mock_ne_v1, mock_torch): + # Настройка моков + mock_ne_v1.return_value = self.mock_model + mock_torch.tensor.return_value = MagicMock() + mock_torch.ones.return_value = MagicMock() + + # Мокаем результат модели + mock_logits = MagicMock() + mock_logits.size.return_value = [1, 6] + self.mock_model.return_value = (mock_logits, MagicMock(), MagicMock()) + + # Мокаем topk + mock_topk_result = MagicMock() + mock_topk_result.indices = MagicMock() + mock_topk_result.indices.size.return_value = [1, 3] + mock_topk_result.indices.__getitem__.return_value = MagicMock() + mock_topk_result.indices.__getitem__.return_value.item.return_value = 0 + mock_topk_result.values = MagicMock() + mock_topk_result.values.__getitem__.return_value = MagicMock() + mock_topk_result.values.__getitem__.return_value.item.return_value = 0.8 + + mock_torch.topk.return_value = mock_topk_result + + # Мокаем softmax + mock_torch.softmax.return_value = mock_topk_result.values + + # Импортируем после мока + from ne_qiki.core.neural_engine_impl import NeuralEngineV1 + from ne_qiki.shared.models import Proposal, ActuatorCommand + from dataclasses import dataclass + + @dataclass + class MockBiosStatus: + ok: bool = True + temperature: float = 50.0 + power_draw: float = 50.0 + utilization: float = 50.0 + + @dataclass + class MockContext: + bios_status: MockBiosStatus + fsm_state: str = "ACTIVE" + sensor_data: dict = None + + # Конфигурация + config = { + "window": 16, + "in_dim": 32, + "num_classes": 6, + "param_dim": 4, + "topk": 3, + "min_confidence": 0.55, + "time_budget_ms": 8, + "calibration": {"temperature": 1.2}, + "action_catalog": { + "actions": [ + {"name": "HOLD_POSITION", "params": {}}, + ] + } + } + + # Создаем engine + engine = NeuralEngineV1(config) + + # Создаем контекст + context = MockContext(bios_status=MockBiosStatus()) + + # Мокаем глобальный логгер + with patch('ne_qiki.core.neural_engine_impl.GLOBAL_LOGGER') as mock_logger: + # Вызываем generate_proposals + proposals = engine.generate_proposals(context) + + # Проверяем, что логгер был вызван + mock_logger.add_log.assert_called() + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_proposal_evaluator.py b/tests/test_proposal_evaluator.py new file mode 100644 index 0000000..1c9cfa1 --- /dev/null +++ b/tests/test_proposal_evaluator.py @@ -0,0 +1,20 @@ +import unittest +from core.proposal_evaluator import ProposalEvaluator +from shared.models import Proposal, ActuatorCommand + +class TestProposalEvaluator(unittest.TestCase): + def setUp(self): + self.evaluator = ProposalEvaluator() + + def test_evaluate(self): + proposals = [ + Proposal("1", "test", 0.6, 0.9, "test", [ActuatorCommand("HOLD", {})]), + Proposal("2", "test", 0.4, 0.8, "test", [ActuatorCommand("MOVE", {})]), + Proposal("3", "test", 0.7, 0.7, "test", [ActuatorCommand("STOP", {})]) + ] + result = self.evaluator.evaluate(proposals) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].proposal_id, "1") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_safety_shield.py b/tests/test_safety_shield.py new file mode 100644 index 0000000..9082777 --- /dev/null +++ b/tests/test_safety_shield.py @@ -0,0 +1,40 @@ +import unittest +from core.safety import SafetyShield +from shared.models import Proposal, ActuatorCommand + +class TestSafetyShield(unittest.TestCase): + def setUp(self): + self.catalog = { + "actions": [ + {"name": "HOLD_POSITION", "params": {}}, + {"name": "THROTTLE_UP", "params": {"level": [0.0, 1.0]}} + ] + } + self.shield = SafetyShield(self.catalog) + + def test_validate_ok(self): + proposal = Proposal( + proposal_id="test", + source_module_id="test", + confidence=0.9, + priority=0.8, + justification="test", + proposed_actions=[ActuatorCommand("HOLD_POSITION", {})] + ) + result = self.shield.validate([proposal], "ACTIVE", True) + self.assertEqual(len(result), 1) + + def test_validate_error_state(self): + proposal = Proposal( + proposal_id="test", + source_module_id="test", + confidence=0.9, + priority=0.8, + justification="test", + proposed_actions=[ActuatorCommand("HOLD_POSITION", {})] + ) + result = self.shield.validate([proposal], "ERROR_STATE", True) + self.assertEqual(len(result), 0) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_dashboard.py b/tests/test_terminal_dashboard.py new file mode 100644 index 0000000..b9a8c6a --- /dev/null +++ b/tests/test_terminal_dashboard.py @@ -0,0 +1,110 @@ +import unittest +from unittest.mock import patch, MagicMock +import sys +import os +import json + +# Добавляем путь к tools +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools')) + +class TestTerminalDashboard(unittest.TestCase): + def setUp(self): + # Мокаем зависимости prompt_toolkit + self.mock_application = MagicMock() + self.mock_layout = MagicMock() + self.mock_bindings = MagicMock() + + # Мокаем requests + self.mock_requests = MagicMock() + + @patch('tools.terminal_dashboard.requests') + def test_query_prometheus_success(self, mock_requests): + # Подготовка мока + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'status': 'success', + 'data': { + 'result': [{'value': [None, '125.5']}] + } + } + mock_requests.get.return_value = mock_response + + # Импортируем после мока + from tools.terminal_dashboard import TerminalDashboard + dashboard = TerminalDashboard() + + # Тест + result = dashboard.query_prometheus('test_query') + self.assertEqual(result, 125.5) + + @patch('tools.terminal_dashboard.requests') + def test_query_prometheus_failure(self, mock_requests): + # Мокаем ошибку + mock_requests.get.side_effect = Exception("Connection failed") + + from tools.terminal_dashboard import TerminalDashboard + dashboard = TerminalDashboard() + + # Тест + result = dashboard.query_prometheus('test_query') + self.assertEqual(result, 0.0) + + def test_add_log(self): + from tools.terminal_dashboard import TerminalDashboard + dashboard = TerminalDashboard() + + # Добавляем логи + dashboard.add_log("Test log 1") + dashboard.add_log("Test log 2") + + # Проверяем + self.assertIn("Test log 1", dashboard.logs_text.text) + self.assertIn("Test log 2", dashboard.logs_text.text) + + def test_log_limit(self): + from tools.terminal_dashboard import TerminalDashboard + dashboard = TerminalDashboard() + + # Добавляем больше 50 логов + for i in range(60): + dashboard.add_log(f"Log {i}") + + # Проверяем, что осталось только 50 + lines = dashboard.logs_text.text.strip().split('\n') + self.assertEqual(len(lines), 50) + self.assertIn("Log 59", dashboard.logs_text.text) # Последний + self.assertNotIn("Log 0", dashboard.logs_text.text) # Первый удалён + + @patch('tools.terminal_dashboard.threading') + def test_auto_refresh_starts_thread(self, mock_threading): + from tools.terminal_dashboard import TerminalDashboard + dashboard = TerminalDashboard() + + # Мокаем метод update_metrics + dashboard.update_metrics = MagicMock() + + # Вызываем auto_refresh + dashboard.auto_refresh() + + # Проверяем, что thread был создан + mock_threading.Thread.assert_called() + + def test_metrics_text_format(self): + from tools.terminal_dashboard import TerminalDashboard + dashboard = TerminalDashboard() + + # Мокаем query_prometheus + dashboard.query_prometheus = MagicMock(return_value=125.5) + + # Обновляем метрики + dashboard.update_metrics() + + # Проверяем формат + text = dashboard.metrics_text.text + self.assertIn("Inference Rate", text) + self.assertIn("125.50", text) + self.assertIn("QIKI Neural Engine Metrics", text) + +if __name__ == '__main__': + unittest.main() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..4d868e2 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +# Tools package diff --git a/tools/generate_dataset.py b/tools/generate_dataset.py new file mode 100644 index 0000000..9e6200e --- /dev/null +++ b/tools/generate_dataset.py @@ -0,0 +1,35 @@ +import json +import random +import numpy as np + +def generate_sample(): + """Генерирует один пример контекста и действия""" + fsm_states = ["BOOTING", "IDLE", "ACTIVE", "ERROR_STATE"] + actions = list(range(6)) + + # Контекст + features = np.random.randn(32).tolist() + + # Метки + label = random.choice(actions) + priority = random.random() + params = np.random.randn(4).tolist() + mask = [True, True, True, True, False, False] + + return { + "features": features, + "label": label, + "priority": priority, + "params": params, + "action_mask": mask + } + +def generate_dataset(path="dataset.jsonl", size=1000): + with open(path, 'w') as f: + for _ in range(size): + sample = generate_sample() + f.write(json.dumps(sample) + '\n') + print(f"Dataset saved to {path}") + +if __name__ == "__main__": + generate_dataset() diff --git a/tools/global_logger.py b/tools/global_logger.py new file mode 100644 index 0000000..8e21e68 --- /dev/null +++ b/tools/global_logger.py @@ -0,0 +1,13 @@ +class GlobalLogger: + def __init__(self): + self.dashboard = None + + def set_dashboard(self, dashboard): + self.dashboard = dashboard + + def add_log(self, message): + if self.dashboard: + self.dashboard.add_log(message) + +# Глобальный экземпляр +GLOBAL_LOGGER = GlobalLogger() diff --git a/tools/terminal_dashboard.py b/tools/terminal_dashboard.py new file mode 100644 index 0000000..f644b44 --- /dev/null +++ b/tools/terminal_dashboard.py @@ -0,0 +1,152 @@ +import asyncio +import requests +import json +from datetime import datetime +from prompt_toolkit import Application +from prompt_toolkit.layout import Layout, HSplit, VSplit +from prompt_toolkit.widgets import Frame, TextArea, Label +from prompt_toolkit.layout.containers import Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.key_binding import KeyBindings +import threading +import time + +class TerminalDashboard: + def __init__(self, prometheus_url="http://localhost:9090"): + self.prometheus_url = prometheus_url + self.running = True + + # UI Components + self.title_label = Label("QIKI Neural Engine - Terminal Dashboard") + self.status_label = Label("Status: Running") + self.metrics_text = TextArea(text="Loading metrics...", height=10) + self.logs_text = TextArea(text="Waiting for logs...", height=10) + self.footer_label = Label("Press 'q' to quit | Auto-refresh: 2s") + + # Layout + self.root_container = HSplit([ + Frame(self.title_label, style="class:title"), + Frame(self.status_label, style="class:status"), + Frame(Label("Metrics"), body=self.metrics_text), + Frame(Label("Recent Logs"), body=self.logs_text), + Frame(self.footer_label, style="class:footer") + ]) + + self.layout = Layout(self.root_container) + + # Key bindings + self.bindings = KeyBindings() + + @self.bindings.add('q') + def _(event): + self.running = False + event.app.exit() + + @self.bindings.add('c-c') + def _(event): + self.running = False + event.app.exit() + + # App + self.app = Application( + layout=self.layout, + key_bindings=self.bindings, + full_screen=True + ) + + # Data storage + self.logs = [] + + def query_prometheus(self, query): + """Query Prometheus API""" + try: + url = f"{self.prometheus_url}/api/v1/query" + response = requests.get(url, params={'query': query}, timeout=2) + if response.status_code == 200: + data = response.json() + if data['status'] == 'success' and data['data']['result']: + return float(data['data']['result'][0]['value'][1]) + return 0.0 + except: + return 0.0 + + def update_metrics(self): + """Update metrics display""" + try: + # Get metrics + inference_rate = self.query_prometheus('rate(ne_inference_total[1m])') + latency = self.query_prometheus('histogram_quantile(0.95, rate(ne_inference_duration_seconds_bucket[1m]))') + avg_confidence = self.query_prometheus('avg(ne_avg_confidence)') + + # Format metrics text + metrics_text = f"""QIKI Neural Engine Metrics +==================================================================================================== +Inference Rate: {inference_rate:.2f} infers/sec +95th Latency: {latency*1000:.2f} ms +Avg Confidence: {avg_confidence:.3f} +Safety Blocks: {self.query_prometheus('ne_safety_blocks_total')} +Degradations: {self.query_prometheus('ne_degradation_to_rule')} +==================================================================================================== +""" + self.metrics_text.text = metrics_text + + except Exception as e: + self.metrics_text.text = f"Error fetching metrics: {str(e)}" + + def add_log(self, log_entry): + """Add log entry to display""" + timestamp = datetime.now().strftime("%H:%M:%S") + self.logs.append(f"[{timestamp}] {log_entry}") + # Keep only last 50 logs + self.logs = self.logs[-50:] + self.logs_text.text = "\n".join(self.logs) + + def auto_refresh(self): + """Auto-refresh metrics""" + while self.running: + try: + self.update_metrics() + time.sleep(2) + except: + pass + + def run(self): + """Run the dashboard""" + # Start auto-refresh thread + refresh_thread = threading.Thread(target=self.auto_refresh, daemon=True) + refresh_thread.start() + + # Run application + self.app.run() + + self.running = False + +# Пример использования +if __name__ == "__main__": + dashboard = TerminalDashboard() + + # Добавим тестовые логи + def simulate_logs(): + import time + import random + logs = [ + "NeuralEngine: Proposal generated (HOLD_POSITION, conf=0.87)", + "SafetyShield: Action validated OK", + "ProposalEvaluator: Selected priority=0.92", + "FSM: State changed to ACTIVE", + "BIOS: All systems OK", + "Timeout: 6.2ms (within budget)", + "Warning: Low confidence (0.45) - degrading to RuleEngine" + ] + while dashboard.running: + if random.random() < 0.3: # 30% chance per 0.5s + log = random.choice(logs) + dashboard.add_log(log) + time.sleep(0.5) + + # Start log simulation + log_thread = threading.Thread(target=simulate_logs, daemon=True) + log_thread.start() + + # Run dashboard + dashboard.run() diff --git a/train_bc.py b/train_bc.py new file mode 100644 index 0000000..409e644 --- /dev/null +++ b/train_bc.py @@ -0,0 +1,76 @@ +import torch +import torch.nn as nn +from torch.utils.data import Dataset, DataLoader +from ne_qiki.models.ne_v1 import NE_v1 +import onnx +import onnxruntime as ort +from onnxruntime.quantization import quantize_dynamic, QuantType + +# Пример датасета +class MockDataset(Dataset): + def __init__(self, size=1000): + self.size = size + + def __len__(self): + return self.size + + def __getitem__(self, idx): + x = torch.randn(16, 32) + y_class = torch.randint(0, 6, (1,)).long() + y_priority = torch.rand(1) + y_params = torch.rand(4) + mask = torch.ones(6).bool() + return x, y_class, y_priority, y_params, mask + +def train_model(): + model = NE_v1(32, 64, 6, 4) + dataset = MockDataset(1000) + loader = DataLoader(dataset, batch_size=8, shuffle=True) + criterion_cls = nn.CrossEntropyLoss() + criterion_reg = nn.MSELoss() + optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) + + model.train() + for epoch in range(5): + for x, y_cls, y_prio, y_param, mask in loader: + logits, prio, param = model(x, mask) + loss = criterion_cls(logits, y_cls) + \ + criterion_reg(prio, y_prio) + \ + criterion_reg(param, y_param) + optimizer.zero_grad() + loss.backward() + optimizer.step() + print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}") + + torch.save(model.state_dict(), "ne_v1.pt") + print("Model saved as ne_v1.pt") + + # Экспорт в ONNX + model.eval() + dummy_input = torch.randn(1, 16, 32) + dummy_mask = torch.ones(1, 6).bool() + torch.onnx.export( + model, + (dummy_input, dummy_mask), + "ne_v1.onnx", + export_params=True, + opset_version=11, + do_constant_folding=True, + input_names=["input", "mask"], + output_names=["logits", "priority", "params"], + dynamic_axes={ + "input": {0: "batch", 1: "time"}, + "mask": {0: "batch"}, + "logits": {0: "batch"}, + "priority": {0: "batch"}, + "params": {0: "batch"} + } + ) + print("ONNX model exported as ne_v1.onnx") + + # Квантизация + quantize_dynamic("ne_v1.onnx", "ne_v1_int8.onnx", weight_type=QuantType.QUInt8) + print("INT8 quantized model saved as ne_v1_int8.onnx") + +if __name__ == "__main__": + train_model()