feat: maximize sorter demo realism and add safe agent MVP

Wire live classifyItem into continuous playback, add jam/E-stop cases,
presenter controls (seek/speed/hotkeys), quality modes, demo scripts,
audit docs, and a verify-only autonomous agent CLI with hard safety limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-07-15 16:53:35 +00:00
parent e89c728dd2
commit 985f7c327d
40 changed files with 3358 additions and 453 deletions

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ vite.config.d.ts
.env.*
npm-debug.log*
input_info/extracted/
agent/state/
agent/reports/
.demo-preview.pid
.demo-preview.log

View File

@@ -39,21 +39,28 @@ A → подающий конвейер → накопитель → CV/laser/ul
### Автоматическая демонстрация
**Главная возможность** — Auto Demo:
- **ВАЖНО**: Сначала нажмите "3D Digital Twin" toggle для включения 3D (по умолчанию 2D fallback на первой загрузке)
- Нажмите "🎬 Запустить автодемо" для полного автоматического цикла
- Товар автоматически: появляется → сканируется → классифицируется → маршрутизируется в B/C/D
- Controls: Пауза/Продолжить, Остановить, Reset
- Режимы: Автоматический (по умолчанию) или ручной (Next step)
**Главная страница `/`** — Continuous Playback (10 кейсов: B/C/D + low confidence + jam + E-stop):
1. Откройте сайт и нажмите **Play Demo** (или Space).
2. Управление:
- **Space** — play/pause
- **N / →** — следующий кейс, **B / ←** — предыдущий
- **1–0** — прыжок к кейсу
- **R** — аварийный reset сценария
- **P** — presentation mode, **F** — fullscreen
- **E** — журнал событий
- **0.5×–2×** — скорость
3. HUD показывает live `classifyItem` (DIM / K / reason) — не заскриптованный override.
4. Engineering / step demo: `/details`
Проверка: `npm test` (153+), `npm run build`, `./scripts/demo-health.sh`.
**3D Verification Checklist** (для защиты):
1. Открыть https://arhipovdan.ru/ в desktop Chrome/Edge
2. Нажать "3D Digital Twin" toggle
3. Проверить: 3D scene renders (green floor, A/B/C/D zones visible)
4. Нажать "🎬 Запустить автодемо"
5. Проверить: NO black screen, item moves, FPS stable (~60)
6. Открыть Console (F12): no errors
7. Проверить mobile (<640px): 2D fallback автоматически
1. Desktop Chrome/Edge → Play Demo
2. Нет чёрного экрана, товар движется, FPS стабилен (~30–60)
3. Console без ошибок
4. Кейсы 9–10: jam (FAULT) и emergency stop
5. Mobile (<640px): упрощённая сцена / fallback
**Реальные 3D модели** (6 STL, 55%):
- Бутылка, Тарелка, Цилиндр, Короб 300, Короб 400, ЛанчБокс

315
agent/cli.mjs Executable file
View File

@@ -0,0 +1,315 @@
#!/usr/bin/env node
/**
* Autonomous improvement agent MVP — Orchestrator shell.
*
* Modes: dry-run | run-once | status | stop | resume | pause | report
* NEVER merges to main or deploys production.
*/
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
appendFileSync,
unlinkSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const STATE_DIR = join(ROOT, 'agent', 'state');
const REPORTS_DIR = join(ROOT, 'agent', 'reports');
const KILL_SWITCH = join(STATE_DIR, 'KILL');
const STATUS_FILE = join(STATE_DIR, 'status.json');
const AUDIT_LOG = join(STATE_DIR, 'audit.jsonl');
const LIMITS = {
maxCycleMinutes: 25,
maxChangedFiles: 12,
maxDiffLines: 800,
maxParallelWorkers: 1,
dailyLlmBudgetUsd: 5,
forbidMergeToMain: true,
forbidProductionDeploy: true,
forbidSecretAccess: true,
requireTestsPass: true,
requireBuildPass: true,
minScoreDelta: 0,
};
function ensureDirs() {
mkdirSync(STATE_DIR, { recursive: true });
mkdirSync(REPORTS_DIR, { recursive: true });
}
function nowIso() {
return new Date().toISOString();
}
function audit(event, payload = {}) {
appendFileSync(AUDIT_LOG, `${JSON.stringify({ ts: nowIso(), event, ...payload })}\n`);
}
function writeStatus(status) {
writeFileSync(STATUS_FILE, JSON.stringify({ ...status, updatedAt: nowIso() }, null, 2));
}
function readStatus() {
if (!existsSync(STATUS_FILE)) return { state: 'idle' };
return JSON.parse(readFileSync(STATUS_FILE, 'utf8'));
}
function isKilled() {
return existsSync(KILL_SWITCH);
}
function run(cmd, args) {
const result = spawnSync(cmd, args, {
cwd: ROOT,
encoding: 'utf8',
timeout: 15 * 60 * 1000,
env: { ...process.env, AGENT_MODE: '1' },
});
return {
code: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
function collectBaseline() {
const tests = run('npm', ['test']);
const build = run('npm', ['run', 'build']);
return {
testsPassed: tests.code === 0,
buildPassed: build.code === 0,
testOutputTail: tests.stdout.split('\n').slice(-20).join('\n'),
buildOutputTail: build.stdout.split('\n').slice(-20).join('\n'),
};
}
function proposeTasks(baseline) {
const backlog = [
{
id: 'unify-classifier-proof',
title: 'Keep classifyItem as single decision source on main demo',
impact: 9,
risk: 2,
},
{
id: 'safety-playlist',
title: 'Jam / E-stop visible on main playlist',
impact: 8,
risk: 3,
},
{
id: 'demo-hotkeys',
title: 'Presenter seek/speed/presentation hotkeys',
impact: 7,
risk: 2,
},
{
id: 'perf-quality-modes',
title: 'Adaptive quality modes for demo FPS',
impact: 6,
risk: 2,
},
];
if (!baseline.testsPassed || !baseline.buildPassed) {
return {
id: 'stabilize-baseline',
title: 'Restore green tests/build before further changes',
impact: 10,
risk: 1,
};
}
return backlog.sort((a, b) => b.impact - a.impact)[0];
}
function scoreCategories(baseline, notes) {
const categories = {
visualRealism: 72,
physicsFidelity: 68,
demoClarity: 80,
technicalStability: baseline.testsPassed && baseline.buildPassed ? 88 : 40,
performance: 78,
faultTolerance: 75,
architecture: 76,
testCoverage: 82,
launchConvenience: 84,
logging: 74,
security: 90,
hardwareFit: 86,
};
const values = Object.values(categories);
const total = Math.round(values.reduce((a, b) => a + b, 0) / values.length);
return { categories, total, notes };
}
function writeReport(runId, report) {
const path = join(REPORTS_DIR, `${runId}.json`);
writeFileSync(path, JSON.stringify(report, null, 2));
writeFileSync(join(REPORTS_DIR, 'latest.json'), JSON.stringify(report, null, 2));
return path;
}
function cmdDryRun() {
ensureDirs();
if (isKilled()) {
console.error('Kill switch active. Remove agent/state/KILL to continue.');
process.exit(2);
}
const runId = `dry-${Date.now()}`;
writeStatus({ state: 'dry-run', runId });
audit('dry_run_start', { runId });
console.log(`[agent] dry-run ${runId}`);
console.log('[agent] collecting baseline (tests + build)...');
const baseline = collectBaseline();
const task = proposeTasks(baseline);
const score = scoreCategories(baseline, 'dry-run — no code changes');
const report = {
runId,
mode: 'dry-run',
hypothesis: `If we execute "${task.title}", demo jury clarity improves without regressing FPS.`,
task,
limits: LIMITS,
baseline,
score,
decision: 'PLAN_ONLY',
reason: 'dry-run does not apply patches',
safety: {
wouldTouchProduction: false,
wouldMergeMain: false,
killSwitch: false,
},
};
const path = writeReport(runId, report);
writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'dry-run' });
audit('dry_run_complete', { runId, path, taskId: task.id });
console.log(`[agent] planned task: ${task.id} — ${task.title}`);
console.log(`[agent] score: ${score.total}/100`);
console.log(`[agent] report: ${path}`);
}
function cmdRunOnce() {
ensureDirs();
if (isKilled()) {
console.error('Kill switch active.');
process.exit(2);
}
const runId = `once-${Date.now()}`;
writeStatus({ state: 'run-once', runId });
audit('run_once_start', { runId });
console.log(`[agent] run-once ${runId}`);
console.log('[agent] MVP policy: verify baseline only — no automatic code mutation.');
const baseline = collectBaseline();
const task = proposeTasks(baseline);
const score = scoreCategories(baseline, 'run-once verification');
const decision =
baseline.testsPassed && baseline.buildPassed ? 'ACCEPT_BASELINE' : 'REJECT_BASELINE';
const report = {
runId,
mode: 'run-once',
task,
limits: LIMITS,
baseline,
score,
decision,
reason:
decision === 'ACCEPT_BASELINE'
? 'Tests and build green; agent will not auto-patch in MVP (requires human Implementer).'
: 'Baseline red — agent refuses further patches until fixed.',
nextSafeActions: [
'Keep working on feature/maximum-demo-realism',
'Never merge to main automatically',
'Use agent dry-run before long autonomous sessions',
],
};
const path = writeReport(runId, report);
writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'run-once', decision });
audit('run_once_complete', { runId, decision });
console.log(`[agent] decision: ${decision}`);
console.log(`[agent] report: ${path}`);
process.exit(decision === 'ACCEPT_BASELINE' ? 0 : 1);
}
function cmdStatus() {
ensureDirs();
console.log(JSON.stringify({ ...readStatus(), killSwitch: isKilled(), limits: LIMITS }, null, 2));
}
function cmdStop() {
ensureDirs();
writeFileSync(KILL_SWITCH, `stopped at ${nowIso()}\n`);
writeStatus({ state: 'stopped' });
audit('kill_switch_on');
console.log('[agent] stop requested — kill switch written');
}
function cmdResume() {
ensureDirs();
if (existsSync(KILL_SWITCH)) unlinkSync(KILL_SWITCH);
writeStatus({ state: 'idle' });
audit('kill_switch_off');
console.log('[agent] resumed');
}
function cmdPause() {
writeStatus({ ...readStatus(), state: 'paused' });
audit('paused');
console.log('[agent] paused');
}
function cmdReport() {
ensureDirs();
const latest = join(REPORTS_DIR, 'latest.json');
if (!existsSync(latest)) {
console.error('No report yet. Run: node agent/cli.mjs dry-run');
process.exit(1);
}
console.log(readFileSync(latest, 'utf8'));
}
const cmd = process.argv[2] ?? 'status';
switch (cmd) {
case 'dry-run':
cmdDryRun();
break;
case 'run-once':
cmdRunOnce();
break;
case 'status':
cmdStatus();
break;
case 'stop':
cmdStop();
break;
case 'resume':
cmdResume();
break;
case 'pause':
cmdPause();
break;
case 'report':
cmdReport();
break;
case 'start':
console.log('[agent] continuous start disabled in MVP. Use: dry-run | run-once');
process.exit(1);
break;
default:
console.log('Usage: node agent/cli.mjs <dry-run|run-once|status|stop|resume|pause|report>');
process.exit(1);
}

View File

@@ -0,0 +1,158 @@
# Лимиты безопасности AI-агента
**Файл реализации лимитов:** `agent/cli.mjs` → объект `LIMITS`
**Дата:** 2026-07-15
Документ обязателен к соблюдению при любом расширении агента за пределы MVP.
---
## 1. Однозначная политика
| Действие | Разрешено автоматически? |
| -------- | ------------------------ |
| Читать репозиторий, запускать tests/build | Да |
| Писать отчёты в `agent/reports` | Да |
| Планировать задачи (dry-run) | Да |
| Патчить исходники (MVP) | **Нет** |
| Merge в `main`/`master` | **Никогда** |
| Deploy production (:3100 / tunnel) | **Никогда** |
| Печать значений секретов / `.env` | **Никогда** |
| Менять firewall/SSH/системные секреты | **Никогда** |
| Force-push | **Никогда** |
---
## 2. Числовые лимиты (MVP)
| Параметр | Значение | Смысл |
| -------- | -------: | ----- |
| `maxCycleMinutes` | 25 | Стена времени одного цикла |
| `maxChangedFiles` | 12 | Потолок будущего патча |
| `maxDiffLines` | 800 | Анти-мегадифф |
| `maxParallelWorkers` | 1 | Ёмкость сервера |
| `dailyLlmBudgetUsd` | 5 | Бюджет API |
| `minScoreDelta` | 0 | Порог «улучшения» для accept (будущее) |
| `requireTestsPass` | true | Gate |
| `requireBuildPass` | true | Gate |
| `forbidMergeToMain` | true | Hard |
| `forbidProductionDeploy` | true | Hard |
| `forbidSecretAccess` | true | Hard |
Playwright (когда будет в цикле): **1 worker**, не параллелить с локальной LLM.
---
## 3. Kill switch
| Операция | Команда / файл |
| -------- | -------------- |
| Остановить | `node agent/cli.mjs stop` → создаёт `agent/state/KILL` |
| Возобновить | `node agent/cli.mjs resume` → удаляет KILL |
| Пауза статуса | `node agent/cli.mjs pause` |
При наличии `KILL` режимы `dry-run` / `run-once` завершаются с ошибкой.
**Перед живой демонстрацией:** всегда `stop`.
---
## 4. Изоляция изменений
| Правило | Деталь |
| ------- | ------ |
| Ветка | Только `feature/*` или `agent/*`; не `main` |
| Backup | Тег `backup/pre-maximum-demo-realism-20260715` уже создан |
| Worktree (рекомендация L2) | Отдельный worktree для патчей |
| Production dist | Не трогать volume/nginx без человека |
| Preview | `:3101` допустим для проверки; не путать с `:3100` |
---
## 5. Что можно / нельзя автоматизировать
### Безопасно автоматизировать
- Запуск Vitest и production build.
- Генерация JSON-отчётов и scorecard.
- Обновление backlog приоритетов.
- Health-check URL (без секретов).
- Сбор метрик размеров `dist` (без PII).
### Только с проверкой человека
- Любой diff по `src/`.
- Изменение demo playlist / classifier thresholds.
- Включение shadows/effects (FPS).
- Зависимости `package.json`.
- Nginx/Docker/Compose манифесты.
- Создание PR (без auto-merge).
### Никогда автоматически
- Merge в main.
- Deploy на https://arhipovdan.ru/ / :3100.
- Ротация credentials, правка `.env`.
- `git push --force`.
- Отключение или ослабление тестов «чтобы стало зелёным».
- Удаление backup-тегов.
- Запуск локальной LLM параллельно с демо-нагрузкой.
---
## 6. Журналирование и аудит
| Артефакт | Требование |
| -------- | ---------- |
| `audit.jsonl` | Append-only; каждое start/stop/complete |
| Reports | Хранить hypothesis, decision, limits snapshot |
| Запрет | Значения `login`/`password`/`url` из `.env` |
При инциденте: приложить `status.json` + хвост `audit.jsonl` + `latest.json` **без** `.env`.
---
## 7. Ресурсные лимиты хоста (операционные)
Согласовано с `SERVER_CAPACITY_REPORT.md`:
| Ресурс | Лимит для агента |
| ------ | ---------------- |
| Workers | 1 |
| RAM budget | ≤4 GiB суммарно с Chromium |
| GPU | не обязателен; не держать 7B 24/7 |
| Расписание | не во время режима «Демонстрация» |
| Disk | ротация старых `agent/reports` при росте |
---
## 8. Реакция на отказы
| Ситуация | Действие агента |
| -------- | --------------- |
| Tests red | `REJECT_BASELINE`, не планировать фичи |
| Build red | то же |
| Kill switch | немедленный выход |
| Budget exceeded | stop + report |
| Попытка deploy/merge | невозможна в коде MVP; при добавлении — hard fail |
---
## 9. Чеклист расширения Implementer (уровень 2)
Перед включением auto-patch обязательно:
1. [ ] Отдельный git worktree.
2. [ ] Проверка `forbidMergeToMain` интеграционными тестами агента.
3. [ ] Max files/diff enforced до `git commit`.
4. [ ] Авто-PR без auto-merge.
5. [ ] Visual QA optional flag, default off on demo days.
6. [ ] Документированный human approver.
Пока пункты не выполнены — Implementer остаётся **выключенным** (текущее состояние).
---
## 10. Вывод
Безопасность агента важнее скорости итераций. На OwlPrime допустим только **узкий, наблюдаемый, обратимый** контур. MVP это соблюдает: verify-only + kill switch + запрет prod.

View File

@@ -0,0 +1,187 @@
# Архитектура автономного AI-агента
**Статус:** MVP реализован (`agent/cli.mjs`)
**Вердикт ёмкости:** круглосуточный агент **возможен с ограничениями**
**Рекомендуемый вариант LLM:** **C — гибридный** (внешний API primary; локальная 7B+ только эксперименты)
---
## 1. Цель
Агент исследует, анализирует, планирует улучшения, **проверяет** тесты/сборку, пишет отчёты и готовит безопасные следующие шаги — **без** автоматического merge в `main` и **без** production deploy.
MVP **не** выполняет auto-patch кода (verify-only / plan-only).
---
## 2. Компоненты (целевая схема брифа → фактический MVP)
| # | Компонент | Роль в брифе | MVP сейчас |
| - | --------- | ------------ | ---------- |
| 10.1 | Orchestrator | Цикл задач, лимиты, kill switch | **Есть** — `agent/cli.mjs` |
| 10.2 | Research Agent | Поиск подходов | Задел в backlog задач |
| 10.3 | Project Analyst | Анализ репо/метрик | Baseline tests+build |
| 10.4 | Planner | Выбор задачи по impact/risk | `proposeTasks()` |
| 10.5 | Implementer | Патчи | **Не авто** — human |
| 10.6 | Test Agent | Vitest/build gates | Встроено в dry-run/run-once |
| 10.7 | Visual QA | Playwright/screenshots | Scripts вручную; не в цикле MVP |
| 10.8 | Physics QA | Инварианты motion | Покрыто частично unit-тестами |
| 10.9 | Critic | Scorecard | `scoreCategories()` |
| 10.10 | Release Manager | Merge/deploy | **Запрещён** всегда |
---
## 3. Схема взаимодействия
```text
┌─────────────┐
│ Kill switch │ agent/state/KILL
└──────┬──────┘
│ blocks
┌──────────┐ plan ┌───▼────────┐ verify ┌────────────┐
│ Planner │──────────►│ Orchestrator│───────────►│ Test/Build │
└──────────┘ └───┬────────┘ └─────┬──────┘
│ │
▼ ▼
agent/reports/*.json ACCEPT / REJECT
│
▼
Human Implementer (feature branch)
│
X ──► main / production (forbidden auto)
```
---
## 4. Жизненный цикл задачи
1. **Ingest:** `dry-run` или `run-once`.
2. **Baseline:** `npm test` + `npm run build`.
3. **Propose:** выбрать задачу из backlog (impact/risk) или `stabilize-baseline`.
4. **Score:** категории visual/physics/demo/… → total/100.
5. **Decide:**
- dry-run → `PLAN_ONLY`;
- run-once → `ACCEPT_BASELINE` / `REJECT_BASELINE`.
6. **Persist:** `agent/reports/<id>.json`, `latest.json`, `audit.jsonl`, `status.json`.
7. **Stop conditions:** kill switch, красный baseline, лимиты LIMITS.
*(Будущий уровень 2: Implementer создаёт patch branch → Test → Critic → human merge.)*
---
## 5. Выбор моделей
| Вариант | Описание | Вердикт на OwlPrime |
| ------- | -------- | ------------------- |
| A. Полностью локальный | 7B+ на 2×1080 | Технически возможно, **тяжело**; риск OOM |
| B. Внешние LLM API | Планирование/код вне хоста | Хорошо, нужен бюджет и секреты вне логов |
| **C. Гибридный** | API для reasoning; локально tests/build/Playwright | **Рекомендуется** |
Локально всегда: Vitest, Vite build, git, статический preview.
Внешне: генерация гипотез/диффов (когда Implementer появится).
---
## 6. Хранение состояния
| Путь | Назначение |
| ---- | ---------- |
| `agent/state/status.json` | Текущее состояние (idle/dry-run/…) |
| `agent/state/audit.jsonl` | Append-only журнал событий |
| `agent/state/KILL` | Аварийная остановка |
| `agent/reports/*.json` | Отчёты прогонов |
| `agent/reports/latest.json` | Последний отчёт |
Секреты из `.env` агент **не** должен читать в логи (`forbidSecretAccess: true`).
---
## 7. Очереди
MVP: **очереди нет** — один процесс, `maxParallelWorkers: 1`.
Будущее: файловая очередь `agent/queue/` с lease и TTL, всё ещё single worker на этом хосте.
---
## 8. Безопасность
См. полный документ `AI_AGENT_SAFETY_LIMITS.md`. Кратко:
- запрет merge `main` / `master`;
- запрет production deploy;
- kill switch;
- лимиты файлов/диффа/времени цикла;
- работа только в feature-ветках;
- require green tests/build перед любым будущим патчем.
---
## 9. Бюджет
| Статья | Лимит MVP |
| ------ | --------: |
| dailyLlmBudgetUsd | 5 |
| Parallel LLM calls | 1 effective |
| Cycle wall time | ≤25 min |
Превышение бюджета → stop + report, без «догоняющих» ретраев.
---
## 10. Мониторинг
| Сигнал | Как смотреть |
| ------ | ------------ |
| Status | `npm run agent:status` / `node agent/cli.mjs status` |
| Audit | `agent/state/audit.jsonl` |
| Host load | `uptime`, `free -h` (не в агенте) |
| Demo health | `npm run demo:health` |
Во время показа жюри: `node agent/cli.mjs stop`.
---
## 11. Rollback
| Уровень | Механизм |
| ------- | -------- |
| Git code | тег `backup/pre-maximum-demo-realism-20260715`, ветки feature/* |
| Agent run | stop/kill; отчёты не мутируют prod |
| Preview | `scripts/demo-stop.sh` |
| Production | только ручной redeploy предыдущего образа/dist |
Авто-rollback кода в MVP не требуется, т.к. авто-патча нет.
---
## 12. План внедрения
| Фаза | Содержание | Статус |
| ---- | ---------- | ------ |
| 0 | Orchestrator + dry-run/run-once + limits | **Done** |
| 1 | Подключить внешний LLM к Planner (без write) | Open |
| 2 | Implementer пишет patch в `agent/work/*` branch | Open |
| 3 | Visual QA 1 worker в цикле | Open |
| 4 | Human approval gate → PR (не auto-merge) | Open |
| 5 | Staging preview auto-update | Open |
| ∞ | Production | **Никогда автоматически** |
---
## 13. Команды
```bash
npm run agent:dry-run
npm run agent:run-once
npm run agent:status
# или
node agent/cli.mjs dry-run|run-once|status|stop|resume|pause|report
./scripts/agent-dry-run.sh
./scripts/agent-run-once.sh
```
---
## 14. Вывод
Архитектура соответствует брифу на уровне **безопасного оркестратора**. Полная автономия «исследуй→патчь→деплой» на этом сервере **нецелесообразна и запрещена**. Гибридный Variant C + 1 worker + human merge — единственный устойчивый путь.

View File

@@ -0,0 +1,162 @@
# План максимизации демонстрации
**Проект:** OZON Tech Sorter Simulation
**Ветка реализации:** `feature/maximum-demo-realism`
**Дата:** 2026-07-15
Документ фиксирует целевое состояние демо, этапы и критерии приёмки. Статус этапов: **Done** = реализовано в этой итерации; **Open** = остаётся.
---
## 1. Текущее состояние (as-is)
| Аспект | Состояние | Тип |
| ------ | --------- | --- |
| Движки | Continuous `/` + FSM `/details` | Измерено |
| Классификация на `/` | `classifyItem` wired (live) | Измерено (код) |
| Playlist | 10 кейсов вкл. jam + e-stop | Измерено |
| Proof | HUD DIM/K/reason + CV RULE | Измерено |
| Управление | seek, 0.5–2×, hotkeys, presentation | Измерено |
| Quality | low/medium/high/demo | Измерено |
| Physics | Детерминированная кинематика + jitter | Измерено |
| CV | Pseudo | Измерено |
| Twin parity `/` vs `/details` | Расходятся визуально | Измерено |
| Prod sync | :3100 может быть старым | Измерено (риск) |
| Tests | 153 / 16 files | Измерено |
| Agent | MVP verify-only | Измерено |
---
## 2. Целевое состояние (to-be)
Демонстрация должна за 5–10 секунд отвечать зрителю:
1. Что это за система (промышленная сортировка OZON Tech).
2. Откуда берётся решение (измерения → правила → B/C/D).
3. Что делают механизмы (gate/pusher/маршрут).
4. Что происходит при аварии (jam / e-stop + recover).
5. Что это не «мультфильм»: proof HUD, журнал, метрики.
Целевые свойства:
- визуально реалистичный industrial twin;
- физически правдоподобное (хотя бы кинематически согласованное) движение;
- стабильные 30–60 FPS в режиме demo;
- one-command start + health;
- откат через git tag;
- production = тот же билд, что прошёл тесты.
---
## 3. Этапы
| # | Этап | Приоритет | Сложность | Статус | Ожидаемый эффект |
| - | ---- | --------- | --------- | ------ | ---------------- |
| E1 | Wire `classifyItem` в continuous | P0 | Низкая | **Done** | Доверие к алгоритму |
| E2 | Measurement + DIMENSION_LIMITS + confidence | P0 | Средняя | **Done** | Корректные min dims, reason |
| E3 | Playlist 8→10 + fault timelines | P0 | Средняя | **Done** | Safety story для жюри |
| E4 | Fault freeze/recover + seeded jitter | P1 | Средняя | **Done** | Правдоподобие / воспроизводимость |
| E5 | Demo controls + presentation + journal | P0 | Средняя | **Done** | Управление показом |
| E6 | Quality modes | P1 | Низкая | **Done** | FPS на слабых клиентах |
| E7 | Proof HUD + CV RULE overlay | P0 | Средняя | **Done** | «Видно почему» |
| E8 | simulation tests jam/estop/c_priority | P1 | Низкая | **Done** | Регрессионная защита |
| E9 | Agent MVP + scripts | P1 | Средняя | **Done** | Ops / future autonomy |
| E10 | resolveItem SKU-*-LC | P2 | Низкая | **Done** | Стабильность данных |
| E11 | Унификация визуала `/` и `/details` | P1 | Высокая | **Open** | Единый образ системы |
| E12 | Redeploy prod dist на :3100 | P0 | Низкая (ops) | **Open** | Публичная демо актуальна |
| E13 | Playwright в CI (1 worker) | P2 | Средняя | **Open** | Авто-регрессия UI |
| E14 | Опциональный physics (Rapier lite) | P3 | Высокая | **Open** | Доп. fidelity (не блокер) |
| E15 | Agent auto-patch за human gate | P3 | Высокая | **Open** | Автономия уровня 2 |
---
## 4. Приоритет (матрица)
| Приоритет | Фокус |
| --------- | ----- |
| P0 | То, без чего живой показ врёт или ломается: классификация, proof, faults, controls, prod sync |
| P1 | Убедительность и стабильность: quality, tests, twin unify, agent ops |
| P2 | Удобство и покрытие: e2e CI, data edge cases |
| P3 | Исследования: real physics, full autonomous implementer |
---
## 5. Ожидаемый эффект по направлениям
| Направление | Эффект Done-этапов | Остаточный разрыв |
| ----------- | ------------------ | ----------------- |
| Demo clarity | Высокий — seek/hotkeys/presentation | — |
| Algorithm proof | Высокий — live classify + HUD | Pseudo-CV всё ещё |
| Fault story | Высокий — jam/e-stop в playlist | — |
| Visual realism | Средний+ | Dual twin, нет AO/heavy PBR |
| Physics fidelity | Средний | Нет rigid body |
| Ops | Высокий — scripts + agent | Redeploy path вне docker CLI |
---
## 6. Сложность оставшихся работ
| Работа | Сложность | Зависимости | Риск |
| ------ | --------- | ----------- | ---- |
| Unify twins | Высокая | Общий scene kit, не ломая оба маршрута | Регрессия `/details` |
| Prod redeploy | Низкая | Доступ к docker/host вне среды | Забыть обновить tunnel cache |
| e2e CI | Средняя | 1 worker, артефакты | Flaky screenshots |
| Rapier | Высокая | Performance budget | FPS падение |
| Agent patcher | Высокая | Safety limits, staging | Порча ветки |
---
## 7. Риски плана
| Риск | Митигация |
| ---- | --------- |
| Погоня за «настоящей физикой» убивает FPS | Держать кинематику; physics только opt-in |
| Унификация twin ломает details UX | Feature flag / поэтапный shared module |
| Агент без лимитов | Kill switch + forbid deploy |
| Показ со старым prod | `demo:health` + явный checklist redeploy |
---
## 8. Зависимости
```text
E12 (redeploy) зависит от зелёных tests/build на feature-ветке
E11 (unify) зависит от стабильного continuous twin (E1–E7 Done)
E13 (e2e CI) зависит от ёмкости (1 worker) и стабильных селекторов
E15 (agent patch) зависит от E9 + AI_AGENT_SAFETY_LIMITS
```
---
## 9. Критерии приёмки
### Must (для «максимальной демо» итерации)
- [x] Continuous использует `classifyItem` (не только playlist label).
- [x] Playlist ≥10 с jam и emergency_stop.
- [x] Proof HUD показывает DIM, K, reason.
- [x] Hotkeys + speed + seek + presentation работают.
- [x] Quality modes существуют и покрыты тестами.
- [x] Vitest зелёный (≥153).
- [x] Build OK.
- [x] Backup tag существует.
- [ ] Production https://arhipovdan.ru/ отдаёт новый dist (**Open**).
### Should
- [ ] Визуальный parity ключевых элементов `/` и `/details`.
- [ ] Playwright smoke в CI (1 worker).
### Could
- [ ] Лёгкий physics layer.
- [ ] Agent auto-patch с обязательным human merge.
---
## 10. Рекомендуемый порядок дожима
1. Redeploy production (E12) — максимальный ROI для жюри.
2. Visual unify twin (E11) — доверие «одной системы».
3. e2e smoke (E13).
4. Остальное — по необходимости.

159
docs/DEMO_RUNBOOK.md Normal file
View File

@@ -0,0 +1,159 @@
# Demo Runbook — OZON Tech Sorter Simulation
**Назначение:** провести живую демонстрацию жюри/заказчику без сюрпризов.
**Дата актуализации:** 2026-07-15
**Публичный URL:** https://arhipovdan.ru/
**Репозиторий:** `/home/coder/arhipovdan/app`
---
## 1. За 30–60 минут до показа
### 1.1. Остановить фоновые помехи
```bash
cd /home/coder/arhipovdan/app
node agent/cli.mjs stop
# убедиться, что Playwright/тяжёлые job не бегут
```
### 1.2. Зелёный baseline
```bash
npm test
npm run build
```
Ожидание: Vitest **153** passed / **16** files; build OK.
### 1.3. Health
```bash
npm run demo:health
```
Проверяет:
- `http://127.0.0.1:3100/` (prod loopback),
- `http://127.0.0.1:3101/` (preview, если поднят),
- `https://arhipovdan.ru/`,
- vitest.
### 1.4. Актуальный билд на том URL, который показываете
| Если показываете | Что сделать |
| ---------------- | ----------- |
| Локальный preview | `npm run demo:start` → http://127.0.0.1:3101/ |
| Публичный сайт | Убедиться, что :3100 отдаёт **новый** `dist` (ручной redeploy). Иначе жюри увидит старую версию. |
> В этой среде может не быть `docker` CLI — redeploy выполняется тем процессом, которым контейнер обычно обновляется на OwlPrime.
---
## 2. Старт одной командой
```bash
cd /home/coder/arhipovdan/app
npm run demo:start
```
Скрипт: при необходимости `npm ci`, `npm run build`, поднимает `vite preview` на **127.0.0.1:3101**.
Остановка:
```bash
npm run demo:stop
# reset: bash scripts/demo-reset.sh
```
---
## 3. Сценарий показа (рекомендуемый тайминг ~6–8 мин)
| Мин | Что делать | Что говорит оператор |
| --: | ---------- | -------------------- |
| 0:00 | Открыть `/`, включить Presentation (P/F) | «Цифровой двойник линии сортировки» |
| 0:20 | Дать continuous playback идти | «Товар едет → измеряется → классифицируется» |
| 1:00 | Указать Proof HUD (DIM, K, reason) | «Решение — правила classifyItem, не просто ролик» |
| 2:00 | Кейсы B → C → D | «Габариты / негабарит / круглое сечение» |
| 3:30 | Кейс c_priority | «C приоритетнее D» |
| 4:00 | Jam | «Затор — поток заморожен» |
| 4:40 | Emergency stop | «E-stop — safety stop» |
| 5:20 | Speed 0.5× на сложном кейсе | «Замедляем для разбора» |
| 5:50 | При необходимости `/details` | «Инженерный FSM и сенсоры» — осторожно: визуал twin другой |
| 6:30 | Q&A | Hotkeys 1–0 для прыжка к кейсу |
---
## 4. Hotkeys оператора
| Клавиша | Действие |
| ------- | -------- |
| Space | Пауза / продолжить |
| N | Следующий кейс |
| B | Предыдущий |
| R | Reset |
| P / F | Presentation / fullscreen |
| E | Журнал событий |
| 1–0 | Быстрый переход по кейсам |
Скорость: **0.5×–2×** в demo controls.
---
## 5. Playlist (10 кейсов) — шпаргалка
Порядок storytelling (см. `demoPlaylist.ts`):
1. Короб → **B**
2. ЛанчБокс → **B**
3. Негабарит → **C**
4. Мелкий item (ручка) → **C** (min dims)
5. Тарелка → **D** (K)
6. Бутылка → **D**
7. Oversized+round → **C** (priority)
8. (edge / доп. кейс по playlist)
9. **Jam**
10. **Emergency stop**
Точные id/SKU — в `src/domain/demoPlaylist.ts`.
---
## 6. Если что-то пошло не так
| Симптом | Действие |
| ------- | -------- |
| Чёрный экран / WebGL | Обновить страницу; проверить `ThreeCapabilityCheck`; снизить нагрузку (не demo на слабом GPU клиента) |
| «Не та» логика на сайте | Сравнить с preview :3101; вероятно устарел prod dist → redeploy |
| Завис после jam | R / следующий кейс; не паниковать — freeze ожидаем |
| Красные тесты утром | Не начинать показ; `npm test`, чинить на feature-ветке |
| Агент что-то пишет | `node agent/cli.mjs stop`; MVP не патчит, но stop обязателен |
| Нет сети к публичному URL | Показать loopback :3100 или preview :3101 |
Откат кода:
```bash
git checkout backup/pre-maximum-demo-realism-20260715
```
---
## 7. Что не обещать жюри
- «Настоящий CV с камеры» — нет, pseudo-CV.
- «Полный physics engine» — нет, кинематика.
- «Агент сам выкатывает в prod» — запрещено.
- «`/` и `/details` — идентичная 3D-сцена» — пока нет.
---
## 8. После показа
```bash
npm run demo:stop
node agent/cli.mjs resume # если нужен ночной dry-run
# или оставить stop до следующего окна обслуживания
```
Собрать feedback → занести в backlog агента / issues; не коммитить секреты.

199
docs/FULL_PROJECT_AUDIT.md Normal file
View File

@@ -0,0 +1,199 @@
# Полный аудит проекта — OZON Tech Sorter Simulation
**Дата аудита:** 2026-07-15
**Ветка:** `feature/maximum-demo-realism` (от `dan_branch` @ `e89c728`)
**Точка отката:** тег `backup/pre-maximum-demo-realism-20260715`
**Репозиторий:** `/home/coder/arhipovdan/app`
**Публичный URL:** https://arhipovdan.ru/
| Метка | Значение |
| ----- | -------- |
| Тип данных | Измерено / проверено в репозитории и на сервере |
| Статус | Frontend-only MVP, Track 3 (цифровой двойник сортировки) |
---
## 1. Описание проекта
**OZON Tech Sorter Simulation** — React/Vite/Three.js цифровой двойник промышленной сортировки товаров (Track 3). Система демонстрирует полный контур:
```text
Поступление объекта
→ обнаружение (pseudo-CV / сенсоры)
→ измерения и признаки (габариты, K-roundness)
→ classifyItem (детерминированные правила)
→ управляющий сигнал (gate / pusher)
→ кинематическое перемещение
→ маршрут B / C / D или fault
→ журнал событий + метрики
```
**Характер MVP:** только frontend. Backend, WebSocket, БД, API-сервер — отсутствуют. Сборка — статический `dist`, раздача через nginx (Docker-образ или локальный preview).
**Два движка демонстрации:**
| Маршрут | Движок | Назначение |
| ------- | ------ | ---------- |
| `/` | Continuous playback + playlist | Основная демо для жюри: непрерывный 3D twin, seek/speed, hotkeys |
| `/details` | State-machine simulation | Инженерный разбор: FSM, сенсоры, PID, сценарии |
Оба движка используют общий `classifyItem` и доменные типы, но **визуальные 3D-сцены всё ещё различаются** (`SorterDigitalTwinContinuous` vs `SorterDigitalTwin`).
---
## 2. Архитектура
```text
src/data/items.ts, scenarios.ts, resolveItem.ts, modelAssets.ts
|
v
src/domain/classifier.ts ← единый источник решения (B/C/D)
|
+-- continuousPlayback.ts + demoPlaylist.ts + measurementSystem.ts → MainPage (/)
|
+-- simulation.ts + metrics/pid/sensors → DetailsPage (/details)
|
v
React UI + R3F (Three.js) + Proof HUD / CV overlay / EventLog
```
**Деплой (production-контур):**
```text
Vite build → dist/
→ Docker (node:20-alpine build + nginx:alpine runtime)
→ nginx слушает 127.0.0.1:3100 на хосте OwlPrime
→ cloudflared tunnel → https://arhipovdan.ru/
```
В текущем окружении **нет docker CLI** для оператора; Node 20.20.2 используется для локальной сборки. Production-контейнер мог быть поднят вне этой среды — см. риск устаревшего `dist` на :3100.
---
## 3. Карта модулей
| Область | Путь | Роль |
| ------- | ---- | ---- |
| Точка входа | `src/main.tsx`, `src/App.tsx` | Router: `/`, `/details` |
| Continuous demo | `src/domain/continuousPlayback.ts` | Фазы кейса, таймлайн, fault freeze |
| Playlist | `src/domain/demoPlaylist.ts` | 10 кейсов (классификация + jam + e-stop) |
| Измерения | `src/domain/measurementSystem.ts` | Stepper/laser/stereo → `classifyItem` |
| Классификатор | `src/domain/classifier.ts` | `DIMENSION_LIMITS`, приоритет C над D |
| Кинематика | `src/domain/physicalItemMotion.ts` | Путь по сети конвейера, jitter, freeze |
| Layout | `src/domain/physicalLayout.ts`, `conveyorNetwork.ts` | Единицы мм/м, геометрия линий |
| Качество | `src/domain/qualityMode.ts` | low / medium / high / demo |
| FSM sim | `src/domain/simulation.ts` | State machine для `/details` |
| 3D continuous | `src/components/ThreeD/SorterDigitalTwinContinuous.tsx` | Основной twin |
| 3D details | `src/components/ThreeD/SorterDigitalTwin.tsx` | Инженерный twin |
| Proof UI | `CurrentProofCard`, `CVInspectionOverlay` | DIM / K / reason / RULE |
| Agent MVP | `agent/cli.mjs` | dry-run / run-once / status / stop… |
| Demo scripts | `scripts/demo-*.sh`, `scripts/agent-*.sh` | One-command ops |
---
## 4. Текущий стек
| Слой | Технология | Примечание |
| ---- | ---------- | ---------- |
| Bundler | Vite | Production build ~сотни ms |
| UI | React 19 + TypeScript | SPA |
| 3D | Three.js + @react-three/fiber + drei | Клиентский WebGL |
| Тесты | Vitest | Unit/domain; e2e Playwright — вручную |
| Контейнер | Docker multi-stage + nginx | Статический хостинг |
| Runtime на сервере | nginx :3100, cloudflared | Без pm2 в этой среде |
| Node (build) | 20.20.2 | Измерено |
**Чего нет (подтверждено аудитом):** backend, БД, WebSocket/SSE, PM2, docker CLI в текущем shell-окружении, CI e2e, реальный ML/CV, физический движок (Rapier/Cannon и т.п.).
**Секреты:** файл `.env` присутствует (ключи `login`, `password`, `url`). Значения **не документируются** и в отчёты не включаются.
---
## 5. Выявленные проблемы
| # | Проблема | Критичность | Влияние на демо |
| - | -------- | ----------- | --------------- |
| P1 | Dual 3D twins визуально расходятся (`/` vs `/details`) | Высокая | Зритель может не понять «одну систему» |
| P2 | Нет реального physics engine — детерминированная кинематика | Средняя | При пристальном взгляде нет столкновений/инерции «как в жизни» |
| P3 | Pseudo-CV (измерения из данных SKU, не с камеры) | Средняя | Нужен Proof HUD, иначе «анимация» |
| P4 | Production :3100 может отдавать старый `dist` до redeploy | Высокая | Публичная демо ≠ локальная сборка |
| P5 | Нет e2e в CI; Playwright только scripts | Средняя | Регрессии UI ловятся вручную |
| P6 | Agent MVP не патчит код автоматически | Низкая (by design) | Автономность ограничена verify-only |
| P7 | GPU на сервере idle; 3D на клиенте | Инфо | Серверный offscreen render не нужен |
---
## 6. Критичность (сводка)
| Уровень | Количество | Действие |
| ------- | ---------: | -------- |
| Критическая для живого показа | 1 | Синхронизировать/передеплоить prod dist |
| Высокая (доверие демо) | 2 | Унификация twin + явный proof алгоритма (уже частично сделано) |
| Средняя (техдолг) | 3 | Physics/CV/e2e — план, не блокер MVP |
| Низкая / by design | 1 | Agent без auto-merge |
---
## 7. Технический долг
1. **Два рендерера сцены** — дублирование материалов/освещения/лейаута.
2. **Кинематика вместо физики** — осознанный trade-off производительности и детерминизма.
3. **Pseudo-CV** — confidence фиксирован (0.65 в measurement path после фикса min dims); нет модели.
4. **Ручные Playwright-скрипты** в `scripts/` без интеграции в CI.
5. **Agent** — оркестратор без Implementer-патчей (verify-only).
6. **Документация историческая** в `docs/*_REPORT.md` — много итерационных отчётов; этот аудит — актуальная точка истины на 2026-07-15.
---
## 8. Состояние тестов
| Метрика | До изменений | После изменений | Тип данных |
| ------- | -----------: | --------------: | ---------- |
| Passed | 144 | **153** | Измерено |
| Файлов тестов | 14 | **16** | Измерено |
| Runner | Vitest | Vitest | — |
Добавлено/расширено: `simulation.test.ts` (jam / emergency_stop / c_priority), тесты quality mode и связанные domain-тесты.
**E2E:** не в CI. Скрипты Playwright/Python в `scripts/` — ручной прогон (**оценка процесса**, не автоматический gate).
---
## 9. Состояние production build
| Метрика | До | После | Тип |
| ------- | -- | ----- | --- |
| `npm run build` | OK (~391 ms) | OK | Измерено |
| Main R3F chunk | ~881 kB | (сборка OK; детальный breakdown см. PERFORMANCE_BASELINE) | Измерено / частично |
| CSS | — | 39.66 kB (gzip 8.79) | Измерено |
| Continuous twin chunk | — | 51.29 kB (gzip 13.68) | Измерено |
Команда: `tsc -b && vite build`. Typecheck входит в build pipeline.
---
## 10. Состояние демонстрации
**Сильные стороны (после итерации maximum-demo-realism):**
- `classifyItem` встроен в continuous playback — live classification, не только playlist override.
- Playlist 10 кейсов: B/C/D, edge, c_priority, jam, emergency_stop.
- Demo controls: seek, speed 0.5–2×, hotkeys (Space/N/B/R/P/E/F/1–0), presentation mode, event journal.
- Proof HUD: DIM pass/fail, K, classifier reason; CV overlay с RULE.
- Quality modes: low / medium / high / demo.
- Fault freeze/recover в `physicalItemMotion` + seeded jitter.
- One-command: `npm run demo:start` / `demo:health` / agent scripts.
**Ограничения для жюри:**
- Публичный https://arhipovdan.ru/ может ещё показывать старый билд, пока не пересобран/не перезалит контейнер на :3100.
- Визуальное отличие `/` и `/details`.
- Нет настоящего ML-CV и rigid-body physics.
**Готовность к показу:** высокая для локального preview (`127.0.0.1:3101` через `demo:start`) при зелёных тестах; production — после явного redeploy.
---
## 11. Вывод аудита
Проект — зрелый frontend digital twin с убедительным демо-контуром и измеримой proof-логикой классификации. Главные остаточные риски для показа: **синхронизация production dist** и **визуальная унификация двух twin**. Автономный агент на сервере — **возможен с ограничениями** (1 worker, hybrid LLM, без auto-deploy). Подробности — в `SERVER_CAPACITY_REPORT.md` и `AUTONOMOUS_AI_AGENT_ARCHITECTURE.md`.

View File

@@ -0,0 +1,173 @@
# Отчёт о реализации — maximum-demo-realism
**Ветка:** `feature/maximum-demo-realism`
**База:** `dan_branch` @ `e89c728`
**Backup tag:** `backup/pre-maximum-demo-realism-20260715`
**Дата:** 2026-07-15
**Проект:** OZON Tech Sorter Simulation (Track 3)
---
## 1. Цель итерации
Максимально поднять убедительность живой демонстрации: live-классификация, safety-сценарии, proof UI, управление показом, адаптивное качество, ops-скрипты и безопасный MVP агента — без выдуманных секретов и без auto-deploy.
---
## 2. Все выполненные изменения (реальные)
| # | Изменение | Зачем |
| - | --------- | ----- |
| 1 | `classifyItem` встроен в continuous playback | Live classification, не только подписи playlist |
| 2 | `measurementSystem` → `DIMENSION_LIMITS` + `classifyItem`; min dims; confidence 0.65 | Согласованность измерений и правил |
| 3 | Playlist 8→10: jam + emergency_stop timelines | Safety story для жюри |
| 4 | `physicalItemMotion`: fault freeze/recover + seeded jitter | Правдоподобие и воспроизводимость |
| 5 | Demo controls: seek, speed 0.5–2×, hotkeys Space/N/B/R/P/E/F/1–0, presentation, event journal | Управление живым показом |
| 6 | Quality modes low/medium/high/demo | FPS на разных клиентах |
| 7 | Proof HUD: DIM pass/fail, K, reason; CV overlay RULE | Доказательство работы алгоритма |
| 8 | `simulation.test.ts`: jam / estop / c_priority | Регрессия safety/логики |
| 9 | Agent MVP `agent/cli.mjs`: dry-run\|run-once\|status\|stop\|resume\|pause\|report | Автономия уровня verify-only |
| 10 | Scripts: demo-start/stop/reset/health, agent-dry-run, agent-run-once | One-command ops |
| 11 | `resolveItem` для SKU-*-LC | Стабильность данных |
---
## 3. Изменённые / затронутые области файлов
> Точный `git diff` зависит от незакоммиченного состояния рабочей копии. Ниже — карта модулей по смыслу изменений (**проверено по коду репозитория**).
| Область | Файлы (ключевые) |
| ------- | ---------------- |
| Continuous / classify | `src/domain/continuousPlayback.ts`, `measurementSystem.ts`, `classifier.ts` |
| Playlist / faults | `src/domain/demoPlaylist.ts`, `physicalItemMotion.ts`, `seededRng.ts` |
| UI demo | `src/pages/MainPage.tsx`, Proof/CV components, styles |
| Quality | `src/domain/qualityMode.ts`, `qualityMode.test.ts` |
| Data | `src/data/resolveItem.ts` |
| Tests | `src/domain/simulation.test.ts`, связанные domain tests |
| Agent | `agent/cli.mjs`, `agent/state/*`, `agent/reports/*` |
| Scripts | `scripts/demo-*.sh`, `scripts/agent-*.sh` |
| Package scripts | `package.json` (`demo:*`, `agent:*`) |
| Docs | этот набор `docs/*.md` аудита |
---
## 4. Причины изменений (кратко)
1. Жюри должно **видеть причинно-следственную связь** измерений → правила → маршрут.
2. Без jam/e-stop демо выглядит «идеальной анимацией».
3. Без hotkeys/presentation оператор теряет контроль на сцене.
4. Без quality modes слабые ноутбуки дают рывки и подрывают доверие.
5. Agent/scripts нужны для устойчивой эксплуатации и будущего цикла улучшений **с лимитами**.
---
## 5. Результаты тестов
| Проверка | До | После | Тип |
| -------- | -: | ----: | --- |
| Vitest passed | 144 | **153** | Измерено |
| Test files | 14 | **16** | Измерено |
| E2E CI | нет | нет | Измерено |
| Lint (отдельный) | не выделен в package | не выделен | Измерено |
| Typecheck | через `tsc -b` в build | OK вместе с build | Измерено |
---
## 6. Метрики до и после
| Метрика | До | После | Изменение |
| ------- | -: | ----: | --------: |
| Tests | 144 | 153 | +9 |
| Test files | 14 | 16 | +2 |
| Build | OK ~391 ms | OK | стабильно |
| CSS | — | 39.66 kB / gzip 8.79 | зафиксировано |
| Continuous twin chunk | — | 51.29 kB / gzip 13.68 | зафиксировано |
| Playlist size | 8 | 10 | +faults |
| Agent | нет | MVP CLI | +ops |
| Live classify on `/` | слабо | wired | +proof |
Main R3F chunk baseline до итерации: ~881 kB (**Измерено** historically).
---
## 7. Оставшиеся ограничения
| Ограничение | Комментарий |
| ----------- | ----------- |
| Нет real physics engine | Детерминированная кинематика |
| Pseudo-CV | Synthetic measurements |
| Dual 3D twins diverge | `/` vs `/details` |
| Нет e2e в CI | Playwright вручную |
| Agent без auto-patch | By design MVP |
| Production :3100 | Может служить старый dist до redeploy |
| Нет docker CLI в этой среде | Redeploy — внешняя ops-процедура |
| GPU idle | Не используется для demo render |
---
## 8. Инструкции запуска
### Демо (preview, не путать с prod :3100)
```bash
cd /home/coder/arhipovdan/app
npm run demo:start # build + vite preview 127.0.0.1:3101
npm run demo:health # :3100 / :3101 / public + vitest
npm run demo:stop # остановить preview
# также: bash scripts/demo-reset.sh
```
### Разработка
```bash
npm install # при необходимости
npm run dev # 127.0.0.1:3100 (vite dev — не prod nginx)
npm test
npm run build
```
### Агент
```bash
npm run agent:dry-run
npm run agent:run-once
npm run agent:status
node agent/cli.mjs stop # перед живым показом
node agent/cli.mjs resume
node agent/cli.mjs report
```
### Откат кода
```bash
git fetch --tags
git checkout backup/pre-maximum-demo-realism-20260715
# или сброс ветки к e89c728 по необходимости (только осознанно)
```
### Production
Публичный URL: https://arhipovdan.ru/ (nginx loopback :3100 + cloudflared).
После merge/сборки нужен **ручной redeploy** образа/dist — агент этого не делает.
---
## 9. Hotkeys (оператору демо)
| Клавиша | Действие |
| ------- | -------- |
| Space | Play / pause |
| N | Next case |
| B | Back / previous |
| R | Reset |
| P / F | Presentation / fullscreen-related |
| E | Event journal focus/toggle (UI) |
| 1–0 | Seek/jump по кейсам playlist |
Speed: 0.5×–2× через demo controls UI.
---
## 10. Вывод
Итерация достигла измеримого улучшения демо-контура (тесты +9, playlist +faults, live classify, proof, controls, agent MVP). Критический остаточный ops-риск — **рассинхрон production dist**. Технический потолок реализма без смены архитектуры — кинематика + pseudo-CV; это задокументировано честно.

View File

@@ -0,0 +1,117 @@
# Базовая линия производительности (Performance Baseline)
**Дата:** 2026-07-15
**Ветка:** `feature/maximum-demo-realism`
**Команды:** `npm test`, `npm run build`
Легенда: **Измерено** · **Оценка** · **Не измерено в этом прогоне** (клиентский FPS на железе жюри).
---
## 1. Тесты
| Метрика | Baseline до изменений | После изменений | Тип |
| ------- | --------------------: | --------------: | --- |
| Passed assertions/tests | 144 | **153** | Измерено |
| Test files | 14 | **16** | Измерено |
| Runner | Vitest | Vitest | — |
| Результат | green | green | Измерено |
Дельта: +9 тестов, +2 файла (в т.ч. расширенный `simulation.test.ts`, `qualityMode.test.ts` и связанные).
---
## 2. Production build
| Метрика | До | После | Тип |
| ------- | -- | ----- | --- |
| Build status | OK | OK | Измерено |
| Build time | ~391 ms | OK (порядок сотен ms) | Измерено / частично |
| Pipeline | `tsc -b && vite build` | то же | Измерено |
### Размеры артефактов (после)
| Артефакт | Raw | Gzip | Тип |
| -------- | --: | ---: | --- |
| CSS | 39.66 kB | 8.79 kB | Измерено |
| Continuous twin JS chunk | 51.29 kB | 13.68 kB | Измерено |
| Main R3F-related chunk (до итерации) | ~881 kB | — | Измерено (baseline до) |
> Точный полный rollup всех чанков «после» зависит от конкретного `vite build` output; CSS и continuous twin зафиксированы аудитом. Main Three/R3F chunk исторически доминирует по весу — это ожидаемо.
---
## 3. Runtime (клиент)
| Метрика | Ожидание / факт | Тип |
| ------- | --------------- | --- |
| Рендер 3D | WebGL в браузере клиента | Измерено (архитектура) |
| Серверный 3D | Не используется | Измерено |
| Target FPS demo/high | 60 | Измерено (presets) |
| Target FPS low/medium | 30 | Измерено |
| Shadows / effects | Выключены в presets | Измерено |
| Adaptive quality | `detectQualityMode` / `adaptQuality` | Измерено |
| Фактический FPS 1080p на GTX клиента жюри | Зависит от клиента | **Не измерено** здесь |
Сервер (OwlPrime) при раздаче статики: load ~0.5–0.6 — **не** является bottleneck FPS (**Измерено** нагрузка хоста).
---
## 4. Качество vs производительность (trade-offs)
| Рычаг | Влияние на FPS | Влияние на реализм |
| ----- | -------------- | ------------------ |
| dprMax 1→1.5 | − | + резкость |
| antialias | − | + края |
| shadows on | −− | + контакт с лентой |
| effects/AO | −−− | + «кино» |
| maxVisibleItems | + при снижении | − плотность потока |
| rollerDetail none/sparse | + | − механика роликов |
| Static rollers (perf commit) | + | − вращение |
Текущая политика: **сначала стабильный FPS**, затем дозированный реализм.
---
## 5. Сравнение до / после (сводка)
| Метрика | До | После | Изменение |
| ------- | -: | ----: | --------: |
| Tests passed | 144 | 153 | +9 |
| Test files | 14 | 16 | +2 |
| Build | OK ~391ms | OK | стабильно |
| Demo playlist cases | 8 | 10 | +2 (faults) |
| Live classify on `/` | playlist-heavy | wired `classifyItem` | качество демо ↑ |
| Quality modes | не как система | low…demo | +адаптация |
| CSS gzip | — | 8.79 kB | зафиксировано |
| Continuous chunk gzip | — | 13.68 kB | зафиксировано |
---
## 6. Нагрузка инструментов разработки на сервере
| Инструмент | Оценка стоимости | Рекомендация |
| ---------- | ---------------- | ------------ |
| `npm test` | Умеренная CPU, минуты | OK on-demand |
| `npm run build` | Короткая CPU | OK |
| Playwright 1 worker | Высокая RAM | Не во время демо |
| Local LLM 7B | Очень высокая VRAM/RAM | Избегать на хосте демо |
| Agent dry-run | = tests+build | Лимит 1 worker |
---
## 7. Регрессионные триггеры (когда обновлять baseline)
Переснять baseline, если:
- включены shadows/effects в default demo;
- добавлен physics engine;
- вырос main chunk >20%;
- FPS жалобы на 1366×768;
- изменён continuous twin chunk существенно.
---
## 8. Вывод
Производительность MVP **приемлема** для статического хостинга и клиентского WebGL. Бюджет сознательно защищён отключёнными тенями/эффектами. Главный риск FPS — будущие визуальные «улучшения без лимитов», а не текущий nginx.

View File

@@ -0,0 +1,129 @@
# Аудит физической достоверности
**Дата:** 2026-07-15
**Принцип:** не выдавать красивую анимацию за физическую симуляцию.
Разделение слоёв:
| Слой | Реализация в проекте |
| ---- | -------------------- |
| Визуальный реализм | R3F materials / STL / camera |
| Физическая достоверность | Детерминированная кинематика (`physicalItemMotion`, conveyor network) |
| Логика системы | `classifyItem`, FSM `simulation`, measurement stages |
| Презентационный слой | Playlist, hotkeys, presentation mode, Proof HUD |
---
## 1. Единицы и layout
| Величина | Источник | Статус |
| -------- | -------- | ------ |
| мм / м | `physicalLayout.ts` (`MM_PER_STEP`, высоты лазера, `BELT_TOP_Y`) | Измерено |
| Сеть конвейера | `conveyorNetwork.ts` / path | Измерено |
| Габариты SKU | `items.ts` + `resolveItem` (в т.ч. SKU-*-LC) | Измерено |
| Лимиты габаритов | `DIMENSION_LIMITS` в `classifier.ts` | Измерено |
**Вывод:** единицы согласованы в domain-слое. Это **кинематическая** согласованность, не динамика Ньютона.
---
## 2. Что является «настоящей» логикой vs аппроксимацией
| Узел контура | Реальность в MVP | Тип |
| ------------ | ---------------- | --- |
| Поступление объекта | Playlist / scenario spawn | Детерминированные данные |
| Обнаружение | Phase/sensor flags + pseudo-CV | Аппроксимация |
| Измерения | Модель stepper/laser/stereo из известных размеров | Аппроксимация (честно показана в UI) |
| Классификация | `classifyItem` правила + приоритет C>D | **Реальная логика** |
| Управляющий сигнал | Gate/pusher commands | Реальная логика FSM / playback phases |
| Перемещение | Параметрический путь + speed + fault freeze | Кинематика |
| Столкновения / трение | Нет rigid-body | Отсутствует |
| Подтверждение результата | Category + event log + metrics | Реальная логика учёта |
| Jam / E-stop | Fault timelines + freeze/recover | Симулированные safety-сценарии |
---
## 3. Кинематика предметов
Модуль `physicalItemMotion.ts`:
| Свойство | Поведение | Зачем |
| -------- | --------- | ----- |
| Path following | Движение по сегментам сети | Промышленный маршрут |
| Fault freeze | Остановка при jam/e-stop | Safety demo |
| Recover | Возобновление после reset-потока | Живой показ не «умирает» |
| Seeded jitter | Воспроизводимый шум позиции | Меньше «робот-идеал» |
| Speed scale | 0.5–2× от demo controls | Презентация |
**Нет:** импульсов, restitution, stacking, проскальзывания ленты как friction model, расчёта момента инерции.
---
## 4. Измерительная подсистема
`measurementSystem.ts` моделирует стадии:
`idle → leading_edge → step_counting → laser_height → stereo_width_shape → decision_ready → command_sent`
После доработки:
- использует `DIMENSION_LIMITS` + `classifyItem`;
- исправлены min dimensions;
- confidence **0.65** (зафиксировано в реализации measurement path);
- наружу отдаётся `classificationReason` для Proof HUD.
Это **инженерная визуализация измерений**, а не поток с реальной камеры.
---
## 5. Fault physics vs fault logic
| Сценарий | Логика | Физика движения |
| -------- | ------ | --------------- |
| Jam | Playlist `faultType: jam` + tests | Freeze на конвейере |
| Emergency stop | `emergency_stop` | Freeze; требуется recover/reset narrative |
| C priority | `classifyItem` + sim tests | Маршрут C даже при roundness |
Тесты: `simulation.test.ts` покрывает jam / estop / c_priority (**Измерено**: рост 144→153 тестов).
---
## 6. Инварианты, которые должны держаться
| Инвариант | Статус |
| --------- | ------ |
| Решение на continuous = `classifyItem(item)` | Wired (**Done**) |
| C приоритетнее D при oversized+round | Покрыто тестами |
| Min/max dimensions согласованы с UI Proof | Done (DIMENSION_LIMITS) |
| При fault скорость транспорта = 0 (freeze) | Done |
| Jitter детерминирован seed’ом | Done |
| Нет «телепорта» вне path network | Ожидается; регрессии ловятся visual QA вручную |
---
## 7. Оценка fidelity
| Категория | Балл (0–100) | Тип | Пояснение |
| --------- | -----------: | --- | --------- |
| Logical fidelity | 85 | Оценка | Сильный classifier + FSM |
| Kinematic fidelity | 70 | Оценка | Хороший path, слабые контакты |
| Dynamic fidelity | 25 | Оценка | Нет physics engine |
| Sensor fidelity | 55 | Оценка | Стадии есть, данные synthetic |
| Safety fidelity | 75 | Оценка | Jam/e-stop видимы и тестируются |
*(Баллы — экспертная **оценка**, не бенчмарк.)*
---
## 8. Рекомендации (без иллюзий)
1. **Не** подключать тяжёлый physics engine перед живым показом — риск FPS.
2. Держать честные подписи: «псевдо-CV», «кинематика».
3. При желании повысить fidelity: лёгкие contact constraints только на gate/pusher (opt-in demo mode).
4. Унифицировать twin, чтобы physics story не расходилась визуально между страницами.
---
## 9. Вывод
Проект честно находится в зоне **deterministic kinematic digital twin** с **реальной rule-based классификацией**. Это достаточно для Track 3 MVP и жюри, если proof-слой включён. Называть систему «физическим симулятором с CV» без оговорок — нельзя.

107
docs/RISK_REGISTER.md Normal file
View File

@@ -0,0 +1,107 @@
# Реестр рисков (Risk Register)
**Проект:** OZON Tech Sorter Simulation
**Дата:** 2026-07-15
**Ветка:** `feature/maximum-demo-realism`
Шкала: вероятность / влияние = Low · Medium · High · Critical.
Статус: Open · Mitigated · Accepted.
---
## 1. Сводная таблица
| ID | Риск | Вероятность | Влияние | Статус | Митигация |
| -- | ---- | ----------- | ------- | ------ | --------- |
| R01 | Production :3100 / https://arhipovdan.ru/ отдаёт старый dist | High | Critical | Open | `demo:health`; ручной redeploy; показывать :3101 при сомнении |
| R02 | Визуальный разрыв `/` vs `/details` путает жюри | High | High | Open | Вести показ на `/`; unify twins в плане |
| R03 | Жюри воспринимает кинематику как «фейк» | Medium | High | Mitigated | Proof HUD + честные формулировки; jam/e-stop |
| R04 | Pseudo-CV раскрыт как «обман» | Medium | Medium | Accepted | RULE overlay + confidence; не обещать ML |
| R05 | Просадка FPS на ноутбуке жюри | Medium | High | Mitigated | quality modes; shadows/effects off |
| R06 | Регрессия classifier/min dims | Low | Critical | Mitigated | tests 153; measurement+DIMENSION_LIMITS |
| R07 | Fault freeze без recover ломает показ | Low | High | Mitigated | recover path + hotkeys R/N |
| R08 | Agent/фоновые job портят ресурсы во время демо | Medium | High | Mitigated | kill switch; runbook stop |
| R09 | Будущий auto-patch агента ломает main | Low (сейчас) | Critical | Mitigated | forbid merge/deploy; MVP verify-only |
| R10 | Утечка секретов `.env` в отчёты/логи | Low | Critical | Mitigated | policy forbidSecretAccess; не документировать values |
| R11 | Нет e2e в CI — UI регрессия незамечена | Medium | Medium | Open | ручные scripts; план CI 1 worker |
| R12 | OOM при локальной LLM + Playwright | Medium | High | Accepted (avoid) | Variant C; не совмещать |
| R13 | Disk fill отчётами/скриншотами | Low | Medium | Open | ротация reports |
| R14 | Нет docker CLI в operator env | High (факт) | Medium | Accepted | внешний deploy path документирован |
| R15 | Swap thrash под нагрузкой | Low | High | Mitigated | лимиты 1 worker; demo mode без фона |
| R16 | Расхождение playlist expectedCategory и classifyItem | Low | High | Mitigated | wire classifyItem; tests |
| R17 | STL/fallback выглядят «игрушечно» | Medium | Low | Accepted | modelAssets notes; backlog textures |
| R18 | Tunnel/cloudflared outage | Low | Critical | Open | fallback loopback :3100 / LAN preview |
| R19 | Несогласованность документации и кода | Medium | Low | Mitigated | этот пакет docs = snapshot 2026-07-15 |
| R20 | Попытка «добавить physics» перед показом → регрессия | Medium | High | Open | запрет P0-physics перед demo day |
---
## 2. Детали по критическим рискам
### R01 — Устаревший production dist
**Симптом:** локально proof/hotkeys есть, на https://arhipovdan.ru/ — нет.
**Детектор:** сравнить UI; `demo:health`; hash файлов в контейнере (если доступен docker на хосте).
**Реакция:** ручной redeploy; на показе переключиться на проверенный preview.
**Тип данных о риске:** подтверждён аудитом как **процессный** факт («may still serve OLD dist»).
### R09 — Автономный агент vs production
**Симптом:** гипотетический merge/deploy без человека.
**Текущий контроль:** `forbidMergeToMain`, `forbidProductionDeploy`, нет Implementer auto-patch.
**Остаточный риск:** появится при расширении MVP без обновления safety caps.
### R10 — Секреты
`.env` содержит ключи `login`, `password`, `url`. Значения не подлежат публикации. Любой новый tooling обязан редact’ить env.
---
## 3. Риски ёмкости (связь с SERVER_CAPACITY_REPORT)
| Риск | Вывод |
| ---- | ----- |
| 24/7 agent | Возможен **с ограничениями** |
| Local 7B+ | Тяжело; предпочтителен API |
| Playwright | Да, 1 worker |
| CV training | Лучше external GPU |
| Server 3D | Не нужен |
---
## 4. Матрица приоритета обработки
```text
Сначала: R01 (prod sync), R18 (tunnel fallback plan)
Потом: R02 (twin unify), R11 (e2e), R13 (disk hygiene)
Следить: R05/R08 во время каждого показа
Не трогать в demo week: R20 (physics spike)
```
---
## 5. Accepted risks (осознанно)
| ID | Почему принимаем |
| -- | ---------------- |
| R04 | Архитектура MVP Track 3 — pseudo-CV by design |
| R12 | Не запускаем локальную LLM 24/7 |
| R14 | Ограничение окружения; обход через внешний deploy |
| R17 | ROI текстур ниже proof/controls |
---
## 6. Триггеры пересмотра реестра
- Смена хоста/GPU/RAM.
- Включение Implementer auto-patch.
- Добавление physics engine.
- Подключение реального CV inference.
- Появление CI e2e.
- Инцидент на живом показе.
---
## 7. Вывод
Главный необработанный операционный риск для жюри — **R01 (старый prod dist)**. Технические риски демо-контура в основном **смягчены** итерацией maximum-demo-realism. Риски полной автономии агента **заблокированы политикой**, пока MVP verify-only.

View File

@@ -0,0 +1,176 @@
# Отчёт о ёмкости сервера
**Хост:** OwlPrime (контейнеризованный Ubuntu 24.04)
**Дата измерений:** 2026-07-15
**Приложение:** OZON Tech Sorter Simulation → nginx `127.0.0.1:3100`, tunnel → https://arhipovdan.ru/
Легенда типов данных: **Измерено** = получено диагностикой на сервере; **Оценка** = вывод по мощности без полного бенчмарка нагрузки.
---
## 1. Точные характеристики сервера
| Ресурс | Фактическое значение | Тип |
| ------ | -------------------: | --- |
| CPU | Intel i5-9400F, 6 ядер, 2.9–4.1 GHz | Измерено |
| RAM | 15 GiB (≈13 GiB available) | Измерено |
| Swap | 15 GiB | Измерено |
| Диск | NVMe ~452 G, ~201 G свободно (~54% used) | Измерено |
| GPU | 2× NVIDIA GTX 1080 8 GB, driver 580.126.09, CUDA 13.0 | Измерено |
| GPU load | idle | Измерено |
| Load average | ~0.5–0.6 | Измерено |
| OS | Ubuntu 24.04 (containerized) | Измерено |
| Node (build env) | 20.20.2 | Измерено |
| Docker CLI в этой среде | отсутствует | Измерено |
| PM2 | отсутствует | Измерено |
| App listen | nginx 127.0.0.1:3100 | Измерено |
| Public | cloudflared → https://arhipovdan.ru/ | Измерено |
| Git repo | `/home/coder/arhipovdan/app` | Измерено |
---
## 2. Текущая нагрузка
| Показатель | Значение | Вывод |
| ---------- | -------: | ----- |
| Load avg | 0.5–0.6 при 6 cores | Большой запас CPU |
| GPU | idle | Можно зарезервировать под LLM/CV, но с осторожностью |
| Disk free | ~201 G | Достаточно для node_modules, dist, Playwright cache, артефактов агента |
| Swap | 15 GiB | Есть подушка, но swap thrash недопустим для демо |
**Оценка:** сервер сейчас в «лёгком» режиме (статика nginx + tunnel). Основная 3D-нагрузка — на **клиентском** WebGL, не на сервере.
---
## 3. Доступные ресурсы (безопасный остаток)
| Ресурс | Оценка свободного бюджета | Тип |
| ------ | ------------------------: | --- |
| CPU | 4–5 ядер можно отдавать фону при лимите 1 тяжёлого worker | Оценка |
| RAM | ~8–10 GiB «мягкий» бюджет до давления на swap | Оценка |
| VRAM (2×8 GB) | теоретически хватает на 7B quant; на практике тяжело и конкурирует | Оценка |
| Disk | десятки GB под кэши/отчёты без риска | Оценка |
---
## 4. Допустимые фоновые процессы
| Процесс | Допустимо 24/7? | Условия |
| ------- | --------------- | ------- |
| nginx + cloudflared (prod static) | Да | Без изменения без явного deploy |
| Vite preview :3101 (demo) | Краткосрочно | Не путать с prod :3100 |
| Vitest / build по запросу | Да | Последовательно, не параллельно с LLM |
| Playwright Chromium (1 worker) | Да, по расписанию | Не во время живого показа жюри |
| Agent MVP (verify-only) | Да, с лимитами | См. ниже |
| Локальная LLM 7B+ | Только эксперименты | Предпочтительно внешний API |
| CV training | Нет как 24/7 | Вынести на dedicated GPU / внешний |
| Server-side Three.js offscreen | Возможно, не нужно | Клиентский WebGL достаточен |
---
## 5. Возможность локальных моделей (LLM)
| Вариант | Вердикт | Комментарий |
| ------- | ------- | ----------- |
| Локальная 7B+ на 2×1080 | **Возможна технически, тяжело** | VRAM суммарно 16 GB; квантизация нужна; CPU/RAM конкурируют с Playwright |
| Внешний LLM API | **Рекомендуется** | Hybrid Variant C |
| Полностью локальный агент (Variant A) | Не рекомендуется на этом хосте | Риск OOM / деградации демо |
**Вывод:** локальный инференс — опциональный research-path; production-агент должен использовать **внешний API** с бюджетом.
---
## 6. Возможность Playwright / Chromium
| Критерий | Вердикт |
| -------- | ------- |
| CPU + RAM | Достаточно (**Измерено** ресурсы; **Оценка** нагрузки) |
| Параллелизм | **1 worker** |
| 24/7 непрерывный прогон | Нежелателен; лучше nightly / по событию |
| Конфликт с живой демо | Высокий — не запускать во время показа |
---
## 7. Возможность автономного агента
**Однозначный вывод: возможен с ограничениями.**
| Параметр | Лимит |
| -------- | ----- |
| Parallel workers | 1 |
| LLM | Hybrid (внешний API primary) |
| Auto merge to main | Запрещено |
| Auto production deploy | Запрещено |
| Режим MVP | verify-only (dry-run / run-once), без auto-patch |
| Kill switch | `agent/state/KILL` |
Подробности: `AI_AGENT_SAFETY_LIMITS.md`, `AUTONOMOUS_AI_AGENT_ARCHITECTURE.md`.
---
## 8. Рекомендуемые лимиты
| Лимит | Значение | Источник |
| ----- | -------: | -------- |
| maxParallelWorkers | 1 | Agent MVP + ёмкость |
| maxCycleMinutes | 25 | `agent/cli.mjs` LIMITS |
| maxChangedFiles | 12 | Agent LIMITS |
| maxDiffLines | 800 | Agent LIMITS |
| dailyLlmBudgetUsd | 5 | Agent LIMITS |
| Playwright workers | 1 | Capacity |
| CPU soft cap для агента | ≤50% одного ядра в idle-poll; burst на test/build | Оценка |
| RAM soft cap агент+Chromium | ≤4 GiB | Оценка |
| Запрет | merge main, prod deploy, чтение секретов в логи | Policy |
---
## 9. Режимы эксплуатации
| Режим | Что запущено | Параллелизм | Назначение |
| ----- | ------------ | ----------: | ---------- |
| Минимальный безопасный | nginx :3100 + tunnel | 0 background jobs | Стабильный публичный показ |
| Оптимальный | + редкие `agent dry-run`, nightly tests | 1 | Поддержание качества |
| Максимальный | + Playwright 1w + внешний LLM | 1 | Исследования (не во время демо) |
| Демонстрация | Только prod или preview; агент STOP | 0 | Живое жюри |
| Автономное AI-улучшение | Agent + external LLM, ветки feature/*, no deploy | 1 | Улучшения с human gate |
---
## 10. Сводная таблица ёмкости
| Ресурс | Фактическое значение | Текущая нагрузка | Безопасный лимит | Вывод |
| ------ | -------------------: | ---------------: | ---------------: | ----- |
| CPU 6c | i5-9400F | load ~0.5–0.6 | 1 тяжёлый worker | Запас есть |
| RAM 15 GiB | ≈13 available | низкая | ≤4 GiB агент+browser | OK |
| Swap 15 GiB | — | не давить | Избегать thrash | OK как подушка |
| Disk ~452G | ~201G free | 54% used | Оставить ≥50G free | OK |
| GPU 2×1080 | idle | 0% | Не для prod demo loop | LLM/CV опционально |
| Playwright | — | нет | 1 worker | Да |
| Agent 24/7 | — | MVP | с ограничениями | Да |
| Local LLM 7B+ | — | нет | эксперимент | Тяжело; лучше API |
| CV training | — | нет | external | Не на этом хосте 24/7 |
| Server 3D | — | нет | не требуется | Клиент WebGL |
---
## 11. Риски ёмкости
| Риск | Вероятность | Влияние | Митигация |
| ---- | ----------- | ------- | --------- |
| OOM при локальной LLM + Playwright | Средняя | Падение демо | Не совмещать; API LLM |
| Swap thrash во время показа | Низкая | Лаги tunnel/nginx | Режим «Демонстрация» = stop agent |
| Disk fill артефактами | Низкая | Сборка падает | Ротация `agent/reports`, screenshot dirs |
| Старый dist на :3100 | Высокая (процесс) | Жюри видит не то | Явный redeploy после merge |
| Нет docker CLI здесь | Факт | Сложнее ops | Документировать внешний deploy path |
---
## 12. Нужен ли дополнительный сервер?
| Вопрос | Ответ |
| ------ | ----- |
| Отдельный staging | Желателен при частом агенте; на текущем хосте можно эмулировать preview :3101 |
| Dedicated GPU server | Да, если цель — CV training / тяжёлый local LLM |
| Доп. сервер для static demo | Нет — текущий nginx достаточен |
**Итог:** для demo + ограниченного агента текущий сервер **достаточен**. Для тяжёлого ML — вынести.

View File

@@ -0,0 +1,115 @@
# Аудит визуального реализма
**Дата:** 2026-07-15
**Стек визуализации:** Three.js + React Three Fiber + drei (клиентский WebGL)
**Решение по стеку:** **остаёмся на R3F** — смена на Babylon/Unity Web не обоснована; текущий стек уже даёт industrial twin и STL.
Легенда: **Измерено** (код/сборка/тесты) · **Оценка** (экспертный вывод без GPU-профайлера браузера в этом отчёте).
---
## 1. Цель аудита
Отделить:
1. Что уже выглядит как оборудование.
2. Что всё ещё «игровое» или декоративное.
3. Что критично для доверия жюри за первые 10 секунд.
---
## 2. Текущая визуальная архитектура
| Компонент | Continuous `/` | Details `/details` |
| --------- | -------------- | ------------------ |
| Scene root | `SorterDigitalTwinContinuous` | `SorterDigitalTwin` |
| Items | `PhysicalPlaybackItem` / motion domain | `Item3D` + sim state |
| Conveyor | `Conveyor3D` + network layout | Аналогичные примитивы, другой wiring |
| Actuators | Gate/pusher анимации | State-driven actuators |
| Sensors | Overlay + measurement HUD | `SensorRig3D` / panels |
| Models | STL через `modelAssets` + fallbacks | То же семейство ассетов |
| Camera | Cinematic / demo camera path | Свободная/инженерная |
**Проблема parity:** два twin визуально расходятся — разные акценты освещения/композиции/детализации. Для жюри это снижает ощущение «одной реальной линии».
---
## 3. Чек-лист визуальных факторов
| Фактор | Статус | Комментарий | Тип |
| ------ | ------ | ----------- | --- |
| Геометрия / пропорции | Частично OK | Layout в мм через `physicalLayout` | Измерено |
| Масштаб сцены | OK | Единая физическая раскладка | Измерено |
| Камера / перспектива | OK на `/` | Cinematic playback | Измерено |
| Focal length / DoF | Минимально | DoF не форсируется (мешает чтению) | Оценка |
| Освещение | Среднее | Достаточно для читаемости; не studio HDRI | Оценка |
| Контактные тени | Ограничено | `shadows: false` в quality presets (perf) | Измерено |
| AO / post FX | Выкл. в presets | `effectsEnabled: false` | Измерено |
| Материалы PBR | Базовые | Шероховатость/металл упрощены | Оценка |
| Текстуры / загрязнения | Слабо | Нет сильного wear layer | Оценка |
| Края / bevel | Частично | STL + примитивы | Оценка |
| Движение ленты / ролики | OK с trade-off | Static/sparse rollers в perf-режимах | Измерено (ветка perf) |
| Приводы / задержки | Симулированы кинематикой | Не servo-physics | Измерено |
| Motion blur | Нет | Сознательно | Измерено |
| Anti-aliasing | По режиму | low/medium off; high/demo on | Измерено |
| Звук / вибрация | Нет | Не реализовано | Измерено |
| Заполнение накопителей | Упрощено | B receiver timing учтён в истории perf | Измерено (история) |
| CV overlay | Есть | RULE line + inspection | Измерено |
| Presentation declutter | Есть | Скрытие панелей в presentation | Измерено |
---
## 4. Режимы качества
Источник: `src/domain/qualityMode.ts` (**Измерено**).
| Mode | dprMax | AA | Shadows | maxItems | rollers | target FPS |
| ---- | -----: | -- | ------- | -------: | ------- | ---------: |
| low | 1 | нет | нет | 3 | none | 30 |
| medium | 1.25 | нет | нет | 5 | sparse | 30 |
| high | 1.5 | да | нет | 6 | full | 60 |
| demo | 1.5 | да | нет | 6 | full | 60 |
Автовыбор: по ширине viewport и опционально по recent FPS; `adaptQuality` понижает режим при просадке.
**Целевая демо:** стабильные 60 FPS при возможности, иначе стабильные 30 без рывков (**требование брифа**; фактический FPS на клиенте жюри — **не измерен в этом аудите на всех устройствах**).
---
## 5. Что выглядит убедительно
- Непрерывный путь товара от входа до B/C/D с подписями зон.
- STL-модели там, где ассет доступен; осознанные fallbacks для тяжёлых STL.
- Proof HUD и CV overlay связывают картинку с решением.
- Fault визуально останавливает поток (freeze).
- Presentation mode убирает engineering chrome.
---
## 6. Что снижает доверие («игровое»)
| Симптом | Почему заметно | Рекомендация |
| ------- | -------------- | ------------ |
| Разный вид `/` и `/details` | «Две разные игрушки» | Shared scene kit (Open) |
| Нет теней/AO | Плоские объекты | Осторожно включить в demo на мощных GPU клиентов |
| Pseudo-CV bbox | Идеальная геометрия | Оставить RULE + confidence честно |
| Отсутствие звука | Тихая «сцена» | Опциональный ambient loop (низкий приоритет) |
| Идеально ровное движение без контактов | Нет micro-collisions | Seeded jitter уже частично компенсирует |
---
## 7. Критичность для показа
| Приоритет | Находка | Действие |
| --------- | ------- | -------- |
| P0 | Proof + classification visible | Сделано |
| P0 | Presentation + hotkeys | Сделано |
| P1 | Twin visual unify | Open |
| P2 | Shadows/AO в demo-only | Open, perf-gated |
| P3 | Sound / wear textures | Backlog |
---
## 8. Вывод
Визуальный стек **достаточен**. Узкое место не «не тот движок», а **согласованность двух сцен** и **сдержанный shading ради FPS**. Для жюри важнее читаемый контур решения, чем ray-traced реализм. Следующий максимум ROI: унификация twin + актуальный production dist.

View File

@@ -7,7 +7,13 @@
"dev": "vite --host 127.0.0.1 --port 3100",
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1 --port 3100",
"test": "vitest run"
"test": "vitest run",
"agent:dry-run": "node agent/cli.mjs dry-run",
"agent:run-once": "node agent/cli.mjs run-once",
"agent:status": "node agent/cli.mjs status",
"demo:start": "bash scripts/demo-start.sh",
"demo:stop": "bash scripts/demo-stop.sh",
"demo:health": "bash scripts/demo-health.sh"
},
"dependencies": {
"@react-three/drei": "^10.7.7",

5
scripts/agent-dry-run.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
exec node agent/cli.mjs dry-run

5
scripts/agent-run-once.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
exec node agent/cli.mjs run-once

35
scripts/demo-health.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
ok=0
fail=0
check() {
local name="$1"
local url="$2"
if curl -sf -o /dev/null -w "%{http_code}" "$url" | grep -qE '200|304'; then
echo "[ok] $name ($url)"
ok=$((ok + 1))
else
echo "[fail] $name ($url)"
fail=$((fail + 1))
fi
}
echo "[demo-health] checking endpoints..."
check "prod-loopback" "http://127.0.0.1:3100/" || true
check "preview" "http://127.0.0.1:3101/" || true
check "public" "https://arhipovdan.ru/" || true
echo "[demo-health] running unit tests..."
if npm test >/tmp/demo-health-tests.log 2>&1; then
echo "[ok] vitest"
ok=$((ok + 1))
else
echo "[fail] vitest — see /tmp/demo-health-tests.log"
fail=$((fail + 1))
fi
echo "[demo-health] summary: ok=$ok fail=$fail"
[[ "$fail" -eq 0 ]]

9
scripts/demo-reset.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "[demo-reset] stopping preview..."
"${ROOT}/scripts/demo-stop.sh" || true
echo "[demo-reset] clearing agent kill switch / temp preview logs..."
rm -f "${ROOT}/.demo-preview.log" "${ROOT}/agent/state/KILL" || true
echo "[demo-reset] done — open app and press R (or Stop) to reset demo state in browser"

27
scripts/demo-start.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "[demo-start] installing deps if needed..."
if [[ ! -d node_modules ]]; then
npm ci
fi
echo "[demo-start] building production bundle..."
npm run build
PID_FILE="${ROOT}/.demo-preview.pid"
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "[demo-start] preview already running (pid $(cat "$PID_FILE"))"
exit 0
fi
echo "[demo-start] starting vite preview on 127.0.0.1:3101 (dev preview; prod may already be on :3100)..."
nohup npm run preview -- --host 127.0.0.1 --port 3101 > "${ROOT}/.demo-preview.log" 2>&1 &
echo $! > "$PID_FILE"
sleep 2
curl -sf "http://127.0.0.1:3101/" >/dev/null && echo "[demo-start] OK http://127.0.0.1:3101/" || {
echo "[demo-start] preview not ready yet — check .demo-preview.log"
exit 1
}

15
scripts/demo-stop.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PID_FILE="${ROOT}/.demo-preview.pid"
if [[ -f "$PID_FILE" ]]; then
pid="$(cat "$PID_FILE")"
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" || true
echo "[demo-stop] stopped pid $pid"
fi
rm -f "$PID_FILE"
else
echo "[demo-stop] no preview pid file"
fi
pkill -f "vite preview --host 127.0.0.1 --port 3101" 2>/dev/null || true

View File

@@ -22,7 +22,12 @@ import {
resumePlayback,
stopPlayback,
updatePlayback,
seekToCase,
seekNextCase,
seekPrevCase,
setPlaybackSpeed,
type ContinuousPlaybackState,
type PlaybackSpeed,
} from './domain/continuousPlayback';
function scrollToId(id: string) {
@@ -236,6 +241,22 @@ function AppContent() {
setPlayback(stopPlayback);
}, []);
const handleSeekCase = useCallback((index: number) => {
setPlayback((prev) => seekToCase(prev, index));
}, []);
const handleSeekNext = useCallback(() => {
setPlayback((prev) => seekNextCase(prev));
}, []);
const handleSeekPrev = useCallback(() => {
setPlayback((prev) => seekPrevCase(prev));
}, []);
const handleSetSpeed = useCallback((speed: PlaybackSpeed) => {
setPlayback((prev) => setPlaybackSpeed(prev, speed));
}, []);
return (
<Routes>
<Route
@@ -246,6 +267,10 @@ function AppContent() {
onPlay={handleMainPlay}
onPause={handleMainPause}
onStop={handleMainStop}
onSeekCase={handleSeekCase}
onSeekNext={handleSeekNext}
onSeekPrev={handleSeekPrev}
onSetSpeed={handleSetSpeed}
/>
}
/>

View File

@@ -40,6 +40,8 @@ export default function CVInspectionOverlay({ data, visible }: CVInspectionOverl
command,
cPriorityApplied,
isLowConfidence,
classificationReason,
classificationLabel,
itemTitle,
itemDimensions,
} = data;
@@ -177,6 +179,16 @@ export default function CVInspectionOverlay({ data, visible }: CVInspectionOverl
</div>
)}
{classificationReason && (
<div className="cv-row cv-reason-row">
<span className="cv-label">RULE</span>
<span className="cv-value cv-reason">
{classificationLabel ? `${classificationLabel} — ` : ''}
{classificationReason}
</span>
</div>
)}
<div className="cv-row cv-command-row">
<span className="cv-label">CMD</span>
<span

View File

@@ -1,8 +1,9 @@
import { Suspense, memo, useEffect, useMemo } from 'react';
import { getPhysicalItemPose } from '../../domain/physicalItemMotion';
import { getModelAsset } from '../../data/modelAssets';
import { ITEMS } from '../../data/items';
import { resolveItem } from '../../data/resolveItem';
import type { PlaylistCase } from '../../domain/demoPlaylist';
import { classifyItem } from '../../domain/classifier';
import { getRenderedItemDimensions } from '../../domain/physicalLayout';
import * as THREE from 'three';
import { useLoader } from '@react-three/fiber';
@@ -33,9 +34,6 @@ interface RenderProps {
metalness: number;
}
// STL geometry is cached globally by useLoader (one geometry per URL, shared
// across items). We only render the solid mesh — no per-item wireframe overlay,
// which keeps draw calls and allocations low.
function STLGeometry({ path, scale, color, accentColor, emissiveIntensity, roughness, metalness }: RenderProps & {
path: string;
scale: [number, number, number];
@@ -60,7 +58,6 @@ function FallbackPrimitive({ type, color, accentColor, emissiveIntensity, roughn
type: 'box' | 'cylinder' | 'sphere';
w: number; h: number; d: number;
}) {
// Memoize the geometry once per size so we don't allocate every render.
const geometry = useMemo<THREE.BufferGeometry>(() => {
if (type === 'cylinder' || type === 'sphere') {
const r = Math.max(w, d) / 2;
@@ -81,29 +78,32 @@ export const PhysicalPlaybackItem = memo(function PhysicalPlaybackItem({
caseData,
elapsedMs,
slotIndex = 0,
jitter,
}: {
caseData: PlaylistCase;
elapsedMs: number;
slotIndex?: number;
jitter?: { x: number; z: number; yaw: number };
}) {
const itemId = caseData.itemId.replace('-LC', '');
const itemData = useMemo(() => ITEMS.find(i => i.id === itemId) ?? ITEMS[0], [itemId]);
const itemData = useMemo(() => resolveItem(caseData.itemId), [caseData.itemId]);
const classification = useMemo(() => classifyItem(itemData), [itemData]);
const itemId = itemData.id.replace('-LC', '');
const asset = getModelAsset(itemId);
const dims = getRenderedItemDimensions(itemData.dimensionsMm);
const pose = getPhysicalItemPose({
caseId: caseData.id,
slotIndex,
dimensionsMm: itemData.dimensionsMm,
targetCategory: caseData.expectedCategory,
targetCategory: classification.category,
elapsedMs,
faultType: caseData.faultType,
jitter,
});
const { position, rotation, phase, surface, isSettled } = pose;
const isRouting = phase === 'routing';
const useSimplifiedMesh = phase === 'settled' || isSettled;
// Contact shadow only while the item rides a flat transport surface.
const onTransport = surface === 'main_belt'
|| surface === 'inspection_station'
|| surface === 'routing_junction'
@@ -111,13 +111,14 @@ export const PhysicalPlaybackItem = memo(function PhysicalPlaybackItem({
if (elapsedMs < 0) return null;
const routeAccent = COLORS[caseData.expectedCategory] ?? COLORS.sensorAccent;
const routeAccent = COLORS[classification.category] ?? COLORS.sensorAccent;
const material = ITEM_MATERIALS[itemId] ?? { color: '#d8c3a5', roughness: 0.75 };
const bodyColor = useSimplifiedMesh ? (material.color ?? '#b8b2a8') : material.color;
const bodyColor = phase === 'fault' ? '#ef4444' : useSimplifiedMesh ? (material.color ?? '#b8b2a8') : material.color;
const accentColor = useSimplifiedMesh ? '#94a3b8' : routeAccent;
const emissiveIntensity = useSimplifiedMesh ? 0.01 : (isRouting ? 0.12 : 0.03);
const emissiveIntensity = useSimplifiedMesh
? 0.01
: (phase === 'fault' ? 0.25 : isRouting ? 0.12 : 0.03);
const metalness = material.metalness ?? 0.05;
const useSTL = !useSimplifiedMesh && asset?.loaderType === 'stl' && asset?.frontendAssetPath;
const stlPath = asset?.frontendAssetPath ?? '';
const fallbackType = asset?.fallbackPrimitive ?? 'box';

View File

@@ -22,7 +22,7 @@ import { ITEMS } from '../../data/items';
import type { Category } from '../../domain/types';
import { PhysicalPlaybackItem } from './PhysicalPlaybackItem';
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../../domain/demoPlaylist';
import { CASE_DURATION_MS } from '../../domain/continuousPlayback';
import { cumulativePlaylistDurationMs, getPlaylistCaseDurationMs } from '../../domain/continuousPlayback';
import {
getCameraConfig,
smoothCameraTransition,
@@ -133,13 +133,14 @@ function CinematicCameraController({
// Get item position for camera following
const itemPosition = useMemo(() => {
if (playback.status === 'idle') return null;
const currentItemElapsed = playback.totalElapsedMs - (playback.currentCaseIndex * CASE_DURATION_MS);
const pose = getPhysicalItemPose({
caseId: playback.currentCase.id,
dimensionsMm: { width: 300, depth: 200, height: 200 }, // rough approx for camera target
dimensionsMm: { width: 300, depth: 200, height: 200 },
targetCategory: playback.targetCategory,
elapsedMs: currentItemElapsed,
slotIndex: playback.currentCaseIndex
elapsedMs: playback.caseElapsedMs,
slotIndex: playback.currentCaseIndex,
faultType: playback.currentCase.faultType,
jitter: playback.positionJitter,
});
return pose.position;
}, [playback]);
@@ -1211,8 +1212,9 @@ function ContinuousScene({
// Compute physical items based on elapsed time. Cap the number of
// simultaneously rendered items to keep the scene lightweight.
const { totalElapsedMs, currentCase, currentCaseIndex } = playback;
const casesSpawned = Math.floor(totalElapsedMs / CASE_DURATION_MS) + 1;
// Use cumulative playlist durations (cases may differ: jam / e-stop).
const { totalElapsedMs, currentCase, currentCaseIndex, caseElapsedMs, positionJitter } = playback;
const casesSpawned = currentCaseIndex + 1;
const startIndex = Math.max(0, casesSpawned - MAX_VISIBLE_ITEMS);
const sceneItems = useMemo(() => {
@@ -1220,16 +1222,20 @@ function ContinuousScene({
for (let i = startIndex; i < casesSpawned; i++) {
const playlistIndex = i % PLAYLIST_LENGTH;
const caseData = DEMO_PLAYLIST[playlistIndex];
const elapsedMs = totalElapsedMs - (i * CASE_DURATION_MS);
const caseStart = cumulativePlaylistDurationMs(i);
const elapsedMs = i === currentCaseIndex
? caseElapsedMs
: getPlaylistCaseDurationMs(caseData) + 1; // settled past end for prior cases
items.push({
id: `item-${i}-${caseData.id}`,
slotIndex: i,
caseData,
elapsedMs,
elapsedMs: Math.max(0, elapsedMs),
caseStart,
});
}
return items;
}, [totalElapsedMs, casesSpawned, startIndex]);
}, [caseElapsedMs, casesSpawned, startIndex, currentCaseIndex]);
// Get item data for the current case
const itemId = currentCase.itemId.replace('-LC', '');
@@ -1239,13 +1245,15 @@ function ContinuousScene({
const itemScale = Math.max(dims.width, dims.depth, dims.height);
// Get item position for measurement visualization using physical model
const currentItemElapsed = totalElapsedMs - (currentCaseIndex * CASE_DURATION_MS);
const currentItemElapsed = caseElapsedMs;
const currentPose = getPhysicalItemPose({
caseId: currentCase.id,
dimensionsMm: itemData.dimensionsMm,
targetCategory: category,
elapsedMs: currentItemElapsed,
slotIndex: currentCaseIndex
slotIndex: currentCaseIndex,
faultType: currentCase.faultType,
jitter: positionJitter,
});
const itemPos = currentPose.position;
const activeRoute = currentPose.activeRoute;
@@ -1377,6 +1385,7 @@ function ContinuousScene({
caseData={item.caseData}
elapsedMs={item.elapsedMs}
slotIndex={item.slotIndex}
jitter={item.slotIndex === currentCaseIndex ? positionJitter : undefined}
/>
))}

29
src/data/resolveItem.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Resolve playlist / scenario item ids, including low-confidence variants (SKU-*-LC).
*/
import type { Item } from '../domain/types';
import { getItem, ITEMS } from './items';
/** Strip -LC suffix and apply low confidence when present. */
export function resolveItem(itemId: string): Item {
if (itemId.endsWith('-LC')) {
const baseId = itemId.slice(0, -3);
const base = getItem(baseId);
return {
...base,
id: itemId,
confidence: Math.min(base.confidence, 0.58),
};
}
return getItem(itemId);
}
export function findItemOrFallback(itemId: string): Item {
try {
return resolveItem(itemId);
} catch {
const stripped = itemId.replace(/-LC$/, '');
return ITEMS.find((i) => i.id === stripped) ?? ITEMS[0];
}
}

View File

@@ -123,6 +123,11 @@ export function getCameraModeForPhase(phase: CasePhase): CameraMode {
return 'resultZone';
case 'clear_gap':
return 'nextItemReset';
case 'fault_hold':
case 'emergency_hold':
return 'routingWide';
case 'recover':
return 'overview';
default:
return 'overview';
}

View File

@@ -1,3 +1,7 @@
/**
* Continuous Playback Engine tests + playlist ↔ classifier consistency.
*/
import { describe, it, expect } from 'vitest';
import {
createPlaybackState,
@@ -6,48 +10,44 @@ import {
resumePlayback,
stopPlayback,
updatePlayback,
seekToCase,
setPlaybackSpeed,
CASE_DURATION_MS,
assertPlaylistClassifierConsistency,
type ContinuousPlaybackState,
} from './continuousPlayback';
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from './demoPlaylist';
import { classifyItem } from './classifier';
import { resolveItem } from '../data/resolveItem';
describe('demoPlaylist', () => {
it('has exactly 8 cases', () => {
expect(PLAYLIST_LENGTH).toBe(8);
expect(DEMO_PLAYLIST.length).toBe(8);
it('has classification + safety cases', () => {
expect(PLAYLIST_LENGTH).toBe(10);
expect(DEMO_PLAYLIST.length).toBe(10);
});
it('has no duplicate case ids', () => {
const ids = DEMO_PLAYLIST.map(c => c.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
const ids = DEMO_PLAYLIST.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('has no mixed category (all cases have specific B/C/D)', () => {
const validCategories = ['B', 'C', 'D'];
DEMO_PLAYLIST.forEach(c => {
expect(validCategories).toContain(c.expectedCategory);
});
it('all non-fault cases match live classifyItem', () => {
const mismatches = assertPlaylistClassifierConsistency();
expect(mismatches).toEqual([]);
});
it('all commands would be ROUTE_TO_B/C/D', () => {
DEMO_PLAYLIST.forEach(c => {
const expectedCommand = `ROUTE_TO_${c.expectedCategory}`;
expect(['ROUTE_TO_B', 'ROUTE_TO_C', 'ROUTE_TO_D']).toContain(expectedCommand);
});
});
it('low_confidence case has warning but final category is B/C/D', () => {
const lowConfidence = DEMO_PLAYLIST.find(c => c.id === 'low_confidence');
it('low_confidence case has warning but category from rules', () => {
const lowConfidence = DEMO_PLAYLIST.find((c) => c.id === 'low_confidence');
expect(lowConfidence).toBeDefined();
expect(lowConfidence!.warning).toBeDefined();
expect(['B', 'C', 'D']).toContain(lowConfidence!.expectedCategory);
const result = classifyItem(resolveItem(lowConfidence!.itemId));
expect(result.category).toBe('B');
expect(result.warnings.length).toBeGreaterThan(0);
});
it('has correct case order: B, B, C, C, D, D, C, B', () => {
const expectedOrder = ['B', 'B', 'C', 'C', 'D', 'D', 'C', 'B'];
const actualOrder = DEMO_PLAYLIST.map(c => c.expectedCategory);
expect(actualOrder).toEqual(expectedOrder);
it('includes jam and emergency_stop safety cases', () => {
expect(DEMO_PLAYLIST.some((c) => c.faultType === 'jam')).toBe(true);
expect(DEMO_PLAYLIST.some((c) => c.faultType === 'emergency_stop')).toBe(true);
});
});
@@ -58,112 +58,75 @@ describe('continuousPlayback', () => {
expect(state.currentCaseIndex).toBe(0);
});
it('startPlayback sets status to running', () => {
const state = createPlaybackState();
const running = startPlayback(state);
expect(running.status).toBe('running');
expect(running.currentCaseIndex).toBe(0);
it('startPlayback sets status to running and classifies via classifyItem', () => {
const state = startPlayback(createPlaybackState());
expect(state.status).toBe('running');
expect(state.classification).not.toBeNull();
expect(state.targetCategory).toBe(state.classification!.category);
});
it('pausePlayback sets status to paused', () => {
let state = createPlaybackState();
state = startPlayback(state);
let state = startPlayback(createPlaybackState());
state = pausePlayback(state);
expect(state.status).toBe('paused');
});
it('resumePlayback sets status back to running', () => {
let state = createPlaybackState();
state = startPlayback(state);
let state = startPlayback(createPlaybackState());
state = pausePlayback(state);
state = resumePlayback(state);
expect(state.status).toBe('running');
});
it('stopPlayback resets to idle', () => {
let state = createPlaybackState();
state = startPlayback(state);
state = updatePlayback(state, 1000);
let state = startPlayback(createPlaybackState());
state = stopPlayback(state);
expect(state.status).toBe('idle');
expect(state.currentCaseIndex).toBe(0);
expect(state.totalElapsedMs).toBe(0);
});
it('advances from case 1 to case 2 after case duration', () => {
let state = createPlaybackState();
state = startPlayback(state);
expect(state.currentCaseIndex).toBe(0);
// Simulate passing entire case duration by updating in steps
// Each update processes one phase at a time
it('advances to next case after case duration', () => {
let state = startPlayback(createPlaybackState());
let totalTime = 0;
while (totalTime < CASE_DURATION_MS * 1.5 && state.currentCaseIndex === 0) {
state = updatePlayback(state, 100);
totalTime += 100;
}
expect(state.currentCaseIndex).toBe(1);
expect(state.currentCaseIndex).toBeGreaterThanOrEqual(1);
});
it('status becomes finished after case 8 (without loop)', () => {
let state = createPlaybackState();
state = startPlayback(state);
state = { ...state, loopMode: false };
// Simulate passing all 8 cases by updating in steps
let totalTime = 0;
const maxTime = CASE_DURATION_MS * 10; // Safety limit
while (state.status === 'running' && totalTime < maxTime) {
it('seekToCase jumps to jam case and sets FAULT command later', () => {
const jamIndex = DEMO_PLAYLIST.findIndex((c) => c.faultType === 'jam');
let state = seekToCase(createPlaybackState(), jamIndex);
expect(state.currentCase.faultType).toBe('jam');
for (let i = 0; i < 80; i++) {
state = updatePlayback(state, 100);
totalTime += 100;
if (state.command === 'FAULT') break;
}
expect(state.command).toBe('FAULT');
});
it('setPlaybackSpeed changes multiplier', () => {
let state = startPlayback(createPlaybackState());
state = setPlaybackSpeed(state, 2);
expect(state.speed).toBe(2);
});
it('records classification events in journal', () => {
let state = startPlayback(createPlaybackState());
for (let i = 0; i < 60; i++) {
state = updatePlayback(state, 100);
}
expect(state.events.some((e) => e.type === 'classification' || e.type === 'system')).toBe(true);
});
it('completes playlist', () => {
let state = startPlayback(createPlaybackState());
const maxTime = CASE_DURATION_MS * 20;
let elapsed = 0;
while (state.status === 'running' && elapsed < maxTime) {
state = updatePlayback(state, 200);
elapsed += 200;
}
expect(state.status).toBe('finished');
});
it('loops back to case 1 after case 8 with loopMode', () => {
let state = createPlaybackState();
state = startPlayback(state);
state = { ...state, loopMode: true };
// Simulate passing all 8 cases in steps
let casesPassed = 0;
let totalTime = 0;
const maxTime = CASE_DURATION_MS * 10;
while (casesPassed < 8 && totalTime < maxTime) {
const prevIndex = state.currentCaseIndex;
state = updatePlayback(state, 100);
totalTime += 100;
if (state.currentCaseIndex !== prevIndex || (prevIndex === 7 && state.currentCaseIndex === 0)) {
casesPassed++;
}
}
expect(state.status).toBe('running');
expect(state.currentCaseIndex).toBe(0);
});
it('updates command during routing phase', () => {
let state = createPlaybackState();
state = startPlayback(state);
// Advance to routing phase (roughly halfway through case)
state = updatePlayback(state, CASE_DURATION_MS * 0.7);
// Command should be ROUTE_TO_* based on category
expect(state.command).toMatch(/^ROUTE_TO_[BCD]$|^MOVING_TO_CAMERA$|^DETECTING$|^MEASURING$|^CLASSIFYING$|^RETURN_HOME$/);
});
it('does not update when paused', () => {
let state = createPlaybackState();
state = startPlayback(state);
state = updatePlayback(state, 1000);
const elapsed1 = state.totalElapsedMs;
state = pausePlayback(state);
state = updatePlayback(state, 1000);
expect(state.totalElapsedMs).toBe(elapsed1);
});
});

View File

@@ -1,16 +1,20 @@
/**
* Continuous Playback Engine — manages 8-case auto-demo on main page.
* Controls playlist progression, timing, and phase transitions.
* Continuous Playback Engine — manages auto-demo on main page.
* Classification is always driven by classifyItem (live rule engine).
* Supports speed control, case seek, seeded variability, and event journal.
*/
import type { Category } from './types';
import type { Category, ClassificationResult, EventLogEntry } from './types';
import { DEMO_PLAYLIST, type PlaylistCase, PLAYLIST_LENGTH } from './demoPlaylist';
import { classifyItem } from './classifier';
import { resolveItem } from '../data/resolveItem';
import { createSeededRng, DEFAULT_DEMO_SEED, seededOffset } from './seededRng';
/** Playback status for the continuous demo. */
export type PlaybackStatus = 'idle' | 'running' | 'paused' | 'finished';
/** Phase within a single case timeline. */
export type CasePhase =
export type CasePhase =
| 'spawn'
| 'move_to_detection'
| 'detection'
@@ -19,7 +23,10 @@ export type CasePhase =
| 'command_sent'
| 'routing'
| 'exit'
| 'clear_gap';
| 'clear_gap'
| 'fault_hold'
| 'emergency_hold'
| 'recover';
/** Phase configuration with duration based on conveyor speed (1 m/s). */
export interface PhaseConfig {
@@ -29,25 +36,48 @@ export interface PhaseConfig {
}
/**
* Timeline phases for a single item.
* Timeline phases for a single normal item.
* Distances are in meters, speed is 1 m/s, so duration = distance * 1000 ms.
*/
export const CASE_PHASES: PhaseConfig[] = [
{ phase: 'spawn', durationMs: 300, label: 'Spawn at A' },
{ phase: 'move_to_detection', durationMs: 2500, label: 'Moving to camera' }, // 2.5m at 1m/s
{ phase: 'move_to_detection', durationMs: 2500, label: 'Moving to camera' },
{ phase: 'detection', durationMs: 600, label: 'CV Detection' },
{ phase: 'measurement', durationMs: 1000, label: 'Laser measurement' }, // 1.0m at 1m/s
{ phase: 'classification', durationMs: 1000, label: 'Classification' }, // 1.0m at 1m/s
{ phase: 'command_sent', durationMs: 1000, label: 'Command sent' }, // 1.0m at 1m/s
{ phase: 'routing', durationMs: 2500, label: 'Routing to zone' }, // 2.5m B-route at 1m/s
{ phase: 'measurement', durationMs: 1000, label: 'Laser measurement' },
{ phase: 'classification', durationMs: 1000, label: 'Classification' },
{ phase: 'command_sent', durationMs: 1000, label: 'Command sent' },
{ phase: 'routing', durationMs: 2500, label: 'Routing to zone' },
{ phase: 'exit', durationMs: 400, label: 'Exit to zone' },
{ phase: 'clear_gap', durationMs: 500, label: 'Clear gap' },
];
/** Total duration of one case in ms. */
/** Safety timeline: jam near gate then recover. */
export const JAM_CASE_PHASES: PhaseConfig[] = [
{ phase: 'spawn', durationMs: 300, label: 'Spawn at A' },
{ phase: 'move_to_detection', durationMs: 2000, label: 'Moving to camera' },
{ phase: 'detection', durationMs: 500, label: 'CV Detection' },
{ phase: 'measurement', durationMs: 800, label: 'Laser measurement' },
{ phase: 'classification', durationMs: 600, label: 'Classification' },
{ phase: 'fault_hold', durationMs: 2800, label: 'JAM / FAULT' },
{ phase: 'recover', durationMs: 1200, label: 'Recovery' },
{ phase: 'clear_gap', durationMs: 400, label: 'Clear gap' },
];
/** Safety timeline: emergency stop. */
export const ESTOP_CASE_PHASES: PhaseConfig[] = [
{ phase: 'spawn', durationMs: 300, label: 'Spawn at A' },
{ phase: 'move_to_detection', durationMs: 1800, label: 'Moving to camera' },
{ phase: 'detection', durationMs: 400, label: 'CV Detection' },
{ phase: 'emergency_hold', durationMs: 3000, label: 'EMERGENCY STOP' },
{ phase: 'recover', durationMs: 1500, label: 'System reset' },
{ phase: 'clear_gap', durationMs: 400, label: 'Clear gap' },
];
/** Total duration of one normal case in ms. */
export const CASE_DURATION_MS = CASE_PHASES.reduce((sum, p) => sum + p.durationMs, 0);
/** Continuous playback state. */
export type PlaybackSpeed = 0.5 | 1 | 1.5 | 2;
export interface ContinuousPlaybackState {
status: PlaybackStatus;
currentCaseIndex: number;
@@ -59,13 +89,104 @@ export interface ContinuousPlaybackState {
totalElapsedMs: number;
loopMode: boolean;
targetCategory: Category | null;
classification: ClassificationResult | null;
command: string;
warning: string | null;
/** Playback speed multiplier */
speed: PlaybackSpeed;
/** Seed for reproducible variability */
seed: number;
/** Deterministic position jitter (mm-scale visual offsets stored as meters) */
positionJitter: { x: number; z: number; yaw: number };
/** Bounded event journal for proof / engineering HUD */
events: EventLogEntry[];
}
function phasesForCase(playlistCase: PlaylistCase): PhaseConfig[] {
if (playlistCase.faultType === 'jam') return JAM_CASE_PHASES;
if (playlistCase.faultType === 'emergency_stop') return ESTOP_CASE_PHASES;
return CASE_PHASES;
}
export function getPlaylistCaseDurationMs(playlistCase: PlaylistCase): number {
return phasesForCase(playlistCase).reduce((sum, p) => sum + p.durationMs, 0);
}
/** Cumulative playlist duration before case index (supports wrap for loops). */
export function cumulativePlaylistDurationMs(caseIndex: number): number {
let sum = 0;
for (let i = 0; i < caseIndex; i++) {
sum += getPlaylistCaseDurationMs(DEMO_PLAYLIST[i % PLAYLIST_LENGTH]);
}
return sum;
}
function caseDurationMs(playlistCase: PlaylistCase): number {
return getPlaylistCaseDurationMs(playlistCase);
}
function classifyCase(playlistCase: PlaylistCase): ClassificationResult {
const item = resolveItem(playlistCase.itemId);
return classifyItem(item);
}
function buildJitter(seed: number, caseIndex: number): { x: number; z: number; yaw: number } {
const rng = createSeededRng(seed + caseIndex * 9973);
return {
x: seededOffset(rng, 0.012),
z: seededOffset(rng, 0.008),
yaw: seededOffset(rng, 0.04),
};
}
let eventCounter = 0;
function pushEvent(
events: EventLogEntry[],
timestampMs: number,
entry: Omit<EventLogEntry, 'id' | 'timestampMs'>,
): EventLogEntry[] {
eventCounter += 1;
const next: EventLogEntry = {
id: `pb-evt-${eventCounter}`,
timestampMs,
...entry,
};
return [next, ...events].slice(0, 40);
}
function initCaseFields(playlistCase: PlaylistCase, seed: number, caseIndex: number, events: EventLogEntry[], simTime: number) {
const classification = classifyCase(playlistCase);
const warnings = [
...(playlistCase.warning ? [playlistCase.warning] : []),
...classification.warnings,
];
return {
currentCase: playlistCase,
currentCaseIndex: caseIndex,
currentPhaseIndex: 0,
currentPhase: 'spawn' as CasePhase,
phaseElapsedMs: 0,
caseElapsedMs: 0,
targetCategory: classification.category,
classification,
command: 'IDLE',
warning: warnings[0] ?? null,
positionJitter: buildJitter(seed, caseIndex),
events: pushEvent(events, simTime, {
itemId: playlistCase.itemId,
type: 'system',
message: `Case start: ${playlistCase.title}`,
category: classification.category,
status: 'info',
}),
};
}
/** Create initial playback state. */
export function createPlaybackState(): ContinuousPlaybackState {
export function createPlaybackState(seed: number = DEFAULT_DEMO_SEED): ContinuousPlaybackState {
const firstCase = DEMO_PLAYLIST[0];
const classification = classifyCase(firstCase);
return {
status: 'idle',
currentCaseIndex: 0,
@@ -77,100 +198,100 @@ export function createPlaybackState(): ContinuousPlaybackState {
totalElapsedMs: 0,
loopMode: false,
targetCategory: null,
classification: null,
command: 'IDLE',
warning: null,
speed: 1,
seed,
positionJitter: { x: 0, z: 0, yaw: 0 },
events: [],
};
}
/** Start playback from the beginning. */
export function startPlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
const firstCase = DEMO_PLAYLIST[0];
const fields = initCaseFields(firstCase, state.seed, 0, [], 0);
return {
...state,
status: 'running',
currentCaseIndex: 0,
currentCase: firstCase,
currentPhaseIndex: 0,
currentPhase: 'spawn',
phaseElapsedMs: 0,
caseElapsedMs: 0,
totalElapsedMs: 0,
targetCategory: firstCase.expectedCategory,
command: 'IDLE',
warning: firstCase.warning ?? null,
speed: state.speed,
seed: state.seed,
loopMode: state.loopMode,
...fields,
};
}
/** Pause playback. */
export function pausePlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
if (state.status !== 'running') return state;
return { ...state, status: 'paused' };
}
/** Resume playback. */
export function resumePlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
if (state.status !== 'paused') return state;
return { ...state, status: 'running' };
}
/** Stop and reset playback. */
export function stopPlayback(state: ContinuousPlaybackState): ContinuousPlaybackState {
return createPlaybackState();
return createPlaybackState(state.seed);
}
/** Toggle loop mode. */
export function toggleLoopMode(state: ContinuousPlaybackState): ContinuousPlaybackState {
return { ...state, loopMode: !state.loopMode };
}
/** Advance to the next case. */
function advanceToNextCase(state: ContinuousPlaybackState): ContinuousPlaybackState {
const nextIndex = state.currentCaseIndex + 1;
// Check if we've completed all cases
if (nextIndex >= PLAYLIST_LENGTH) {
if (state.loopMode) {
// Loop back to first case
const firstCase = DEMO_PLAYLIST[0];
return {
...state,
currentCaseIndex: 0,
currentCase: firstCase,
currentPhaseIndex: 0,
currentPhase: 'spawn',
phaseElapsedMs: 0,
caseElapsedMs: 0,
targetCategory: firstCase.expectedCategory,
command: 'IDLE',
warning: firstCase.warning ?? null,
};
} else {
// Finished all cases
return {
...state,
status: 'finished',
command: 'COMPLETE',
};
}
}
// Advance to next case
const nextCase = DEMO_PLAYLIST[nextIndex];
export function setPlaybackSpeed(state: ContinuousPlaybackState, speed: PlaybackSpeed): ContinuousPlaybackState {
return { ...state, speed };
}
/** Jump to a playlist case index (keeps running/paused status). */
export function seekToCase(state: ContinuousPlaybackState, caseIndex: number): ContinuousPlaybackState {
const idx = ((caseIndex % PLAYLIST_LENGTH) + PLAYLIST_LENGTH) % PLAYLIST_LENGTH;
const nextCase = DEMO_PLAYLIST[idx];
const fields = initCaseFields(nextCase, state.seed, idx, state.events, state.totalElapsedMs);
const status = state.status === 'idle' || state.status === 'finished' ? 'running' : state.status;
return {
...state,
currentCaseIndex: nextIndex,
currentCase: nextCase,
currentPhaseIndex: 0,
currentPhase: 'spawn',
phaseElapsedMs: 0,
caseElapsedMs: 0,
targetCategory: nextCase.expectedCategory,
command: 'IDLE',
warning: nextCase.warning ?? null,
status,
...fields,
};
}
/** Get command for current phase. */
export function seekNextCase(state: ContinuousPlaybackState): ContinuousPlaybackState {
return seekToCase(state, state.currentCaseIndex + 1);
}
export function seekPrevCase(state: ContinuousPlaybackState): ContinuousPlaybackState {
return seekToCase(state, state.currentCaseIndex - 1);
}
function advanceToNextCase(state: ContinuousPlaybackState): ContinuousPlaybackState {
const nextIndex = state.currentCaseIndex + 1;
if (nextIndex >= PLAYLIST_LENGTH) {
if (state.loopMode) {
const firstCase = DEMO_PLAYLIST[0];
const fields = initCaseFields(firstCase, state.seed, 0, state.events, state.totalElapsedMs);
return { ...state, ...fields };
}
return {
...state,
status: 'finished',
command: 'COMPLETE',
events: pushEvent(state.events, state.totalElapsedMs, {
type: 'system',
message: 'Playlist complete',
status: 'success',
}),
};
}
const nextCase = DEMO_PLAYLIST[nextIndex];
const fields = initCaseFields(nextCase, state.seed, nextIndex, state.events, state.totalElapsedMs);
return { ...state, ...fields };
}
function getCommandForPhase(phase: CasePhase, category: Category): string {
switch (phase) {
case 'spawn':
@@ -186,6 +307,12 @@ function getCommandForPhase(phase: CasePhase, category: Category): string {
case 'routing':
case 'exit':
return `ROUTE_TO_${category}`;
case 'fault_hold':
return 'FAULT';
case 'emergency_hold':
return 'EMERGENCY_STOP';
case 'recover':
return 'RECOVERING';
case 'clear_gap':
return 'RETURN_HOME';
default:
@@ -193,7 +320,62 @@ function getCommandForPhase(phase: CasePhase, category: Category): string {
}
}
/** Update playback state with elapsed time. */
function maybeLogPhaseTransition(
state: ContinuousPlaybackState,
phase: CasePhase,
category: Category,
): EventLogEntry[] {
let events = state.events;
if (phase === 'classification' && state.classification) {
events = pushEvent(events, state.totalElapsedMs, {
itemId: state.currentCase.itemId,
type: 'classification',
message: `${state.classification.label}: ${state.classification.reason}`,
category: state.classification.category,
command: 'CLASSIFY_RULE_BASED',
status: state.classification.warnings.length ? 'warning' : 'success',
});
}
if (phase === 'command_sent') {
events = pushEvent(events, state.totalElapsedMs, {
itemId: state.currentCase.itemId,
type: 'routing',
message: `Command ROUTE_TO_${category}`,
category,
command: `ROUTE_TO_${category}`,
status: 'success',
});
}
if (phase === 'fault_hold') {
events = pushEvent(events, state.totalElapsedMs, {
itemId: state.currentCase.itemId,
type: 'fault',
message: 'Jam detected at stop-gate — conveyor halted',
command: 'FAULT',
status: 'error',
});
}
if (phase === 'emergency_hold') {
events = pushEvent(events, state.totalElapsedMs, {
itemId: state.currentCase.itemId,
type: 'fault',
message: 'Emergency stop engaged — all motion frozen',
command: 'EMERGENCY_STOP',
status: 'error',
});
}
if (phase === 'recover') {
events = pushEvent(events, state.totalElapsedMs, {
type: 'system',
message: 'Recovery sequence started',
command: 'RECOVER',
status: 'warning',
});
}
return events;
}
/** Update playback state with elapsed wall-clock time (scaled by speed). */
export function updatePlayback(
state: ContinuousPlaybackState,
deltaMs: number,
@@ -202,29 +384,30 @@ export function updatePlayback(
return state;
}
const scaledDelta = deltaMs * state.speed;
let newState = { ...state };
newState.phaseElapsedMs += deltaMs;
newState.caseElapsedMs += deltaMs;
newState.totalElapsedMs += deltaMs;
newState.phaseElapsedMs += scaledDelta;
newState.caseElapsedMs += scaledDelta;
newState.totalElapsedMs += scaledDelta;
const phases = phasesForCase(newState.currentCase);
const currentPhaseConfig = phases[newState.currentPhaseIndex];
// Check if current phase is complete
const currentPhaseConfig = CASE_PHASES[newState.currentPhaseIndex];
if (newState.phaseElapsedMs >= currentPhaseConfig.durationMs) {
// Advance to next phase
const nextPhaseIndex = newState.currentPhaseIndex + 1;
if (nextPhaseIndex >= CASE_PHASES.length) {
// Case complete, advance to next case
if (nextPhaseIndex >= phases.length) {
newState = advanceToNextCase(newState);
} else {
// Move to next phase
newState.currentPhaseIndex = nextPhaseIndex;
newState.currentPhase = CASE_PHASES[nextPhaseIndex].phase;
newState.currentPhase = phases[nextPhaseIndex].phase;
newState.phaseElapsedMs = 0;
if (newState.targetCategory) {
newState.events = maybeLogPhaseTransition(newState, newState.currentPhase, newState.targetCategory);
}
}
}
// Update command based on phase
if (newState.status === 'running' && newState.targetCategory) {
newState.command = getCommandForPhase(newState.currentPhase, newState.targetCategory);
}
@@ -232,35 +415,55 @@ export function updatePlayback(
return newState;
}
/** Get progress within current case (0 to 1). */
export function getCaseProgress(state: ContinuousPlaybackState): number {
return Math.min(state.caseElapsedMs / CASE_DURATION_MS, 1);
export function getCasePhases(state: ContinuousPlaybackState): PhaseConfig[] {
return phasesForCase(state.currentCase);
}
export function getCaseDurationMs(state: ContinuousPlaybackState): number {
return caseDurationMs(state.currentCase);
}
export function getCaseProgress(state: ContinuousPlaybackState): number {
return Math.min(state.caseElapsedMs / getCaseDurationMs(state), 1);
}
/** Get progress within current phase (0 to 1). */
export function getPhaseProgress(state: ContinuousPlaybackState): number {
const phaseConfig = CASE_PHASES[state.currentPhaseIndex];
const phases = getCasePhases(state);
const phaseConfig = phases[state.currentPhaseIndex];
return Math.min(state.phaseElapsedMs / phaseConfig.durationMs, 1);
}
/** Get current phase configuration. */
export function getCurrentPhaseConfig(state: ContinuousPlaybackState): PhaseConfig {
return CASE_PHASES[state.currentPhaseIndex];
return getCasePhases(state)[state.currentPhaseIndex];
}
/** Check if detection is active. */
export function isDetectionActive(state: ContinuousPlaybackState): boolean {
return state.currentPhase === 'detection' || state.currentPhase === 'measurement';
}
/** Check if routing is active. */
export function isRoutingActive(state: ContinuousPlaybackState): boolean {
return state.currentPhase === 'routing' || state.currentPhase === 'exit';
}
/** Get total playlist progress (0 to 1). */
export function isFaultActive(state: ContinuousPlaybackState): boolean {
return state.currentPhase === 'fault_hold' || state.currentPhase === 'emergency_hold';
}
export function getTotalProgress(state: ContinuousPlaybackState): number {
const completedCases = state.currentCaseIndex;
const currentCaseProgress = getCaseProgress(state);
return (completedCases + currentCaseProgress) / PLAYLIST_LENGTH;
}
/** Assert playlist expectedCategory matches live classifier (for tests). */
export function assertPlaylistClassifierConsistency(): Array<{ id: string; expected: Category; actual: Category }> {
const mismatches: Array<{ id: string; expected: Category; actual: Category }> = [];
for (const c of DEMO_PLAYLIST) {
if (c.faultType) continue;
const result = classifyCase(c);
if (result.category !== c.expectedCategory) {
mismatches.push({ id: c.id, expected: c.expectedCategory, actual: result.category });
}
}
return mismatches;
}

View File

@@ -1,10 +1,12 @@
/**
* Demo Playlist — 8 showcase cases for the main page auto-demo.
* Each case demonstrates a specific classification scenario.
* Demo Playlist — showcase cases for the main page auto-demo.
* Includes classification cases + jam / emergency safety demos.
*/
import type { Category, ScenarioId } from './types';
export type FaultType = 'jam' | 'emergency_stop';
export interface PlaylistCase {
id: string;
title: string;
@@ -13,11 +15,13 @@ export interface PlaylistCase {
scenarioId: ScenarioId;
expectedCategory: Category;
warning?: string;
/** Optional safety fault sequence (no routing). */
faultType?: FaultType;
}
/**
* 8 showcase cases for continuous demo playback.
* Order is designed for storytelling: B → C → D → edge cases.
* Showcase playlist: B → C → D → edge cases → safety.
* Order is designed for storytelling for jury / investors.
*/
export const DEMO_PLAYLIST: PlaylistCase[] = [
{
@@ -85,6 +89,26 @@ export const DEMO_PLAYLIST: PlaylistCase[] = [
expectedCategory: 'B',
warning: 'Low confidence is a warning, not a 4th category. Rules still apply.',
},
{
id: 'jam_fault',
title: 'Заклинивание у gate',
description: 'Jam → FAULT, конвейер остановлен, затем recovery',
itemId: 'SKU-004',
scenarioId: 'jam',
expectedCategory: 'C',
faultType: 'jam',
warning: 'FAULT: item jammed at stop-gate',
},
{
id: 'emergency_stop',
title: 'Аварийная остановка',
description: 'E-STOP — все приводы заморожены, затем reset',
itemId: 'SKU-001',
scenarioId: 'emergency_stop',
expectedCategory: 'B',
faultType: 'emergency_stop',
warning: 'EMERGENCY STOP engaged',
},
];
/**

View File

@@ -40,6 +40,9 @@ const PHASE_LABELS: Record<CasePhase, string> = {
routing: 'Routing to zone',
exit: 'Exiting',
clear_gap: 'Clear',
fault_hold: 'FAULT / Jam',
emergency_hold: 'EMERGENCY STOP',
recover: 'Recovering',
};
const VISIBLE_PHASES: CasePhase[] = [
@@ -48,6 +51,8 @@ const VISIBLE_PHASES: CasePhase[] = [
'classification',
'command_sent',
'routing',
'fault_hold',
'emergency_hold',
];
function normalizeShape(shape: string, roundness: number): DetectedShape {

View File

@@ -1,28 +1,24 @@
/**
* Measurement System — engineering logic for item measurement.
*
*
* Measurement architecture:
* 1. Stepper motor: measures length along conveyor axis (step counting)
* 2. Laser rangefinder: measures height from above
* 3. Stereo camera: measures width and determines shape/roundness
* 4. PLC controller: synchronizes and makes classification decision
* 4. PLC controller: synchronizes and makes classification decision via classifyItem
*/
import type { ContinuousPlaybackState, CasePhase } from './continuousPlayback';
import type { Category } from './types';
import { ITEMS } from '../data/items';
import { classifyItem, DIMENSION_LIMITS } from './classifier';
import { resolveItem } from '../data/resolveItem';
import {
MM_PER_STEP,
LASER_HEIGHT_M,
BELT_TOP_Y,
CONVEYOR_SPEED_MPS,
} from './physicalLayout';
// =========================================================
// Types
// =========================================================
export type MeasurementStage =
export type MeasurementStage =
| 'idle'
| 'leading_edge_detected'
| 'step_counting'
@@ -32,28 +28,19 @@ export type MeasurementStage =
| 'command_sent';
export interface MeasurementData {
/** Current measurement stage */
stage: MeasurementStage;
/** Stepper motor data */
stepCount: number;
mmPerStep: number;
measuredLengthMm: number;
pulseActive: boolean;
/** Laser rangefinder data */
laserDistanceMm: number;
laserMountHeightMm: number;
measuredHeightMm: number;
laserBeamActive: boolean;
/** Stereo camera data */
measuredWidthMm: number;
roundnessK: number;
stereoActive: boolean;
pointCloudActive: boolean;
/** Classification */
confidence: number;
dimensionsPass: boolean;
shapeResult: 'box' | 'round' | 'irregular';
@@ -61,24 +48,13 @@ export interface MeasurementData {
command: string;
cPriorityApplied: boolean;
isLowConfidence: boolean;
/** Item info */
/** Live classifier reason (proof of algorithm). */
classificationReason: string | null;
classificationLabel: string | null;
itemTitle: string;
itemDimensions: { width: number; depth: number; height: number };
}
// =========================================================
// Dimension limits (OZON Track 3 spec)
// =========================================================
const MAX_DIMENSION_MM = {
width: 450,
depth: 320,
height: 320,
};
// =========================================================
// Stage mapping from playback phase
// =========================================================
function getStageFromPhase(phase: CasePhase): MeasurementStage {
switch (phase) {
case 'spawn':
@@ -93,6 +69,9 @@ function getStageFromPhase(phase: CasePhase): MeasurementStage {
case 'command_sent':
case 'routing':
case 'exit':
case 'fault_hold':
case 'emergency_hold':
case 'recover':
return 'command_sent';
case 'clear_gap':
return 'idle';
@@ -101,99 +80,72 @@ function getStageFromPhase(phase: CasePhase): MeasurementStage {
}
}
// =========================================================
// Check dimension compliance
// =========================================================
function checkDimensionsPass(dims: { width: number; depth: number; height: number }): boolean {
return (
dims.width <= MAX_DIMENSION_MM.width &&
dims.depth <= MAX_DIMENSION_MM.depth &&
dims.height <= MAX_DIMENSION_MM.height
);
}
// =========================================================
// Determine shape from roundness
// =========================================================
function getShapeResult(roundnessK: number): 'box' | 'round' | 'irregular' {
if (roundnessK >= 0.7) return 'round';
if (roundnessK >= DIMENSION_LIMITS.roundnessThreshold) return 'round';
if (roundnessK >= 0.3) return 'box';
return 'irregular';
}
// =========================================================
// Main measurement data getter
// =========================================================
export function getMeasurementData(playback: ContinuousPlaybackState): MeasurementData {
const currentCase = playback.currentCase;
const phase = playback.currentPhase;
const stage = getStageFromPhase(phase);
// Get item data
const itemId = currentCase.itemId.replace('-LC', '');
const itemData = ITEMS.find(i => i.id === itemId) ?? ITEMS[0];
const itemData = resolveItem(currentCase.itemId);
const dims = itemData.dimensionsMm;
// Calculate step count from item length (depth = length along conveyor)
const lengthMm = dims.depth;
const stepCount = Math.round(lengthMm / MM_PER_STEP);
// Laser measurement: distance from laser to top of item
const laserMountHeightMm = LASER_HEIGHT_M * 1000;
const itemTopHeightMm = BELT_TOP_Y * 1000 + dims.height;
const laserDistanceMm = laserMountHeightMm - itemTopHeightMm;
// Derived measurements
const measuredLengthMm = stepCount * MM_PER_STEP;
const measuredHeightMm = laserMountHeightMm - laserDistanceMm;
const measuredWidthMm = dims.width;
const roundnessK = itemData.roundness;
// Classification
const dimensionsPass = checkDimensionsPass(dims);
const classification = classifyItem(itemData);
const dimensionsPass = classification.dimensionsPass;
const shapeResult = getShapeResult(roundnessK);
const confidence = itemData.confidence ?? 0.95;
const isLowConfidence = confidence < 0.8;
// Determine category
const isLowConfidence = confidence < 0.65;
const deciding =
phase !== 'spawn' &&
phase !== 'move_to_detection' &&
phase !== 'clear_gap';
let finalCategory: Category | null = null;
let cPriorityApplied = false;
if (phase !== 'spawn' && phase !== 'move_to_detection') {
if (!dimensionsPass) {
// Dimensions fail → C (overrides roundness)
finalCategory = 'C';
if (roundnessK >= 0.7) {
cPriorityApplied = true;
}
} else if (roundnessK >= 0.7) {
// Round shape → D
finalCategory = 'D';
} else {
// Normal → B
finalCategory = 'B';
}
let classificationReason: string | null = null;
let classificationLabel: string | null = null;
if (deciding) {
// Prefer live result already computed on playback; fall back to classifyItem.
finalCategory = playback.classification?.category ?? classification.category;
classificationReason = playback.classification?.reason ?? classification.reason;
classificationLabel = playback.classification?.label ?? classification.label;
cPriorityApplied =
finalCategory === 'C' && roundnessK >= DIMENSION_LIMITS.roundnessThreshold;
}
// Override with expected category if available (for consistent demo)
if (currentCase.expectedCategory && phase !== 'spawn' && phase !== 'move_to_detection') {
finalCategory = currentCase.expectedCategory;
// Check if C-priority case
if (finalCategory === 'C' && roundnessK >= 0.7) {
cPriorityApplied = true;
}
}
// Command
const command = finalCategory ? `ROUTE_TO_${finalCategory}` : 'STANDBY';
// Active states for visualization
const command =
phase === 'fault_hold'
? 'FAULT'
: phase === 'emergency_hold'
? 'EMERGENCY_STOP'
: finalCategory
? `ROUTE_TO_${finalCategory}`
: 'STANDBY';
const isActivePhase = ['detection', 'measurement', 'classification'].includes(phase);
const pulseActive = stage === 'step_counting' || stage === 'leading_edge_detected';
const laserBeamActive = isActivePhase;
const stereoActive = isActivePhase;
const pointCloudActive = phase === 'measurement' || phase === 'classification';
return {
stage,
stepCount,
@@ -215,32 +167,44 @@ export function getMeasurementData(playback: ContinuousPlaybackState): Measureme
command,
cPriorityApplied,
isLowConfidence,
classificationReason,
classificationLabel,
itemTitle: currentCase.title,
itemDimensions: dims,
};
}
// =========================================================
// Stage labels for UI
// =========================================================
export function getStageLabel(stage: MeasurementStage): string {
switch (stage) {
case 'idle': return 'Standby';
case 'leading_edge_detected': return 'Edge Detected';
case 'step_counting': return 'Counting Steps';
case 'laser_height': return 'Laser Height';
case 'stereo_width_shape': return 'Stereo Analysis';
case 'decision_ready': return 'Decision Ready';
case 'command_sent': return 'Command Sent';
default: return 'Unknown';
case 'idle':
return 'Standby';
case 'leading_edge_detected':
return 'Edge Detected';
case 'step_counting':
return 'Counting Steps';
case 'laser_height':
return 'Laser Height';
case 'stereo_width_shape':
return 'Stereo Analysis';
case 'decision_ready':
return 'Decision Ready';
case 'command_sent':
return 'Command Sent';
default:
return 'Unknown';
}
}
// =========================================================
// Check if measurement visualization should be shown
// =========================================================
export function shouldShowMeasurement(phase: CasePhase): boolean {
return ['detection', 'measurement', 'classification', 'command_sent', 'routing'].includes(phase);
return [
'detection',
'measurement',
'classification',
'command_sent',
'routing',
'fault_hold',
'emergency_hold',
].includes(phase);
}
export function shouldShowLaserBeam(phase: CasePhase): boolean {

View File

@@ -7,7 +7,8 @@
*/
import type { Category, DimensionsMm } from './types';
import { CASE_PHASES } from './continuousPlayback';
import type { FaultType } from './demoPlaylist';
import { CASE_PHASES, JAM_CASE_PHASES, ESTOP_CASE_PHASES } from './continuousPlayback';
import {
SURFACES,
lerp3,
@@ -17,7 +18,7 @@ import {
} from './conveyorNetwork';
export type SurfaceType = SurfaceName;
export type MotionPhase = 'feed' | 'inspection' | 'decision' | 'routing' | 'settled';
export type MotionPhase = 'feed' | 'inspection' | 'decision' | 'routing' | 'settled' | 'fault' | 'recover';
export interface PhysicalItemPose {
position: Vec3;
@@ -34,13 +35,16 @@ export interface PoseInput {
dimensionsMm: DimensionsMm;
targetCategory: Category | null;
elapsedMs: number;
faultType?: FaultType;
/** Optional seeded visual jitter (meters / radians). */
jitter?: { x: number; z: number; yaw: number };
}
/** Cumulative phase start times reconstructed once from CASE_PHASES. */
function phaseStarts() {
/** Cumulative phase start times from a phase table. */
function phaseStartsFrom(phases: typeof CASE_PHASES) {
let cumulative = 0;
const starts: Record<string, number> = {};
for (const p of CASE_PHASES) {
for (const p of phases) {
starts[p.phase] = cumulative;
cumulative += p.durationMs;
}
@@ -51,7 +55,6 @@ function phaseStarts() {
function poseOnSurface(name: SurfaceName, t: number, itemHeightM: number): { pos: Vec3; rotY: number } {
const s = SURFACES[name];
const p = lerp3(s.start, s.end, t);
// Item bottom sits on the surface: center = surfaceTop + half height.
return {
pos: [p[0], p[1] + itemHeightM / 2, p[2]],
rotY: surfaceHeading(name),
@@ -69,9 +72,9 @@ function settledSlot(name: SurfaceName, slotIndex: number, itemHeightM: number):
const cz = (b.minZ + b.maxZ) / 2;
const spanX = b.maxX - b.minX;
const spanZ = b.maxZ - b.minZ;
const col = slotIndex % 3; // 0,1,2
const row = Math.floor(slotIndex / 3) % 2; // 0,1
const offsetX = (col - 1) * (spanX / 3.5); // stays well within bounds
const col = slotIndex % 3;
const row = Math.floor(slotIndex / 3) % 2;
const offsetX = (col - 1) * (spanX / 3.5);
const offsetZ = (row - 0.5) * (spanZ / 3.0);
return {
pos: [cx + offsetX, s.surfaceY + itemHeightM / 2, cz + offsetZ],
@@ -79,12 +82,85 @@ function settledSlot(name: SurfaceName, slotIndex: number, itemHeightM: number):
};
}
function applyJitter(pos: Vec3, rotY: number, jitter?: PoseInput['jitter']): { pos: Vec3; rotY: number } {
if (!jitter) return { pos, rotY };
return {
pos: [pos[0] + jitter.x, pos[1], pos[2] + jitter.z],
rotY: rotY + jitter.yaw,
};
}
/** Jam / E-stop motion: freeze at junction, then recover (no settle into bin). */
function getFaultPose(input: PoseInput): PhysicalItemPose {
const { dimensionsMm, elapsedMs, faultType, jitter } = input;
const itemHeightM = dimensionsMm.height / 1000;
const phases = faultType === 'emergency_stop' ? ESTOP_CASE_PHASES : JAM_CASE_PHASES;
const { starts, total } = phaseStartsFrom(phases);
const feedStart = starts['move_to_detection'] ?? 0;
const inspectStart = starts['detection'] ?? feedStart;
const measureStart = starts['measurement'] ?? inspectStart;
const classifyStart = starts['classification'] ?? measureStart;
const faultStart =
starts['fault_hold'] ?? starts['emergency_hold'] ?? classifyStart;
const recoverStart = starts['recover'] ?? faultStart;
const clearStart = starts['clear_gap'] ?? recoverStart;
let pos: Vec3;
let rotY = 0;
let surface: SurfaceType = 'main_belt';
let phase: MotionPhase = 'feed';
if (elapsedMs <= feedStart) {
const r = poseOnSurface('main_belt', 0, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed';
} else if (elapsedMs <= inspectStart) {
const t = (elapsedMs - feedStart) / Math.max(inspectStart - feedStart, 1);
const r = poseOnSurface('main_belt', t, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed';
} else if (elapsedMs <= faultStart) {
// Hold under inspection / approach gate
const r = poseOnSurface('inspection_station', 0.4, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'inspection_station'; phase = 'inspection';
} else if (elapsedMs <= recoverStart) {
// Freeze at routing junction (jam / e-stop visual)
const r = poseOnSurface('routing_junction', 0.35, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'routing_junction'; phase = 'fault';
} else if (elapsedMs <= clearStart) {
// Slight reverse toward belt for recovery narrative
const t = (elapsedMs - recoverStart) / Math.max(clearStart - recoverStart, 1);
const r = poseOnSurface('routing_junction', 0.35 * (1 - t * 0.5), itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'routing_junction'; phase = 'recover';
} else {
const r = poseOnSurface('main_belt', 0.15, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'recover';
}
const j = applyJitter(pos, rotY, jitter);
if (!Number.isFinite(j.pos[0]) || !Number.isFinite(j.pos[1]) || !Number.isFinite(j.pos[2])) {
j.pos = [SURFACES.main_belt.start[0], SURFACES.main_belt.surfaceY + itemHeightM / 2, 0];
}
return {
position: j.pos,
rotation: [0, j.rotY, 0],
surface,
phase,
isSettled: elapsedMs >= total && false,
activeRoute: (input.targetCategory as 'B' | 'C' | 'D') || 'B',
};
}
export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose {
const { dimensionsMm, targetCategory, elapsedMs, slotIndex = 0 } = input;
if (input.faultType) {
return getFaultPose(input);
}
const { dimensionsMm, targetCategory, elapsedMs, slotIndex = 0, jitter } = input;
const category: 'B' | 'C' | 'D' = (targetCategory as 'B' | 'C' | 'D') || 'B';
const itemHeightM = dimensionsMm.height / 1000;
const { starts, total } = phaseStarts();
const { starts, total } = phaseStartsFrom(CASE_PHASES);
const feedStart = starts['move_to_detection'];
const inspectStart = starts['detection'];
const junctionStart = starts['measurement'];
@@ -98,25 +174,20 @@ export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose {
let isSettled = false;
if (elapsedMs <= feedStart) {
// Spawn dwell at belt entry (A).
const r = poseOnSurface('main_belt', 0, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed';
} else if (elapsedMs <= inspectStart) {
// Feed along the main belt A → inspection.
const t = (elapsedMs - feedStart) / (inspectStart - feedStart);
const r = poseOnSurface('main_belt', t, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'main_belt'; phase = 'feed';
} else if (elapsedMs <= junctionStart) {
// Dwell under the inspection station.
const r = poseOnSurface('inspection_station', 0, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'inspection_station'; phase = 'inspection';
} else if (elapsedMs <= routingStart) {
// Travel to the routing junction.
const t = (elapsedMs - junctionStart) / (routingStart - junctionStart);
const r = poseOnSurface('routing_junction', t, itemHeightM);
pos = r.pos; rotY = r.rotY; surface = 'routing_junction'; phase = 'decision';
} else if (category === 'B') {
// B: short transfer spur → drop chute → settle inside floor bin.
const exitStart = starts['exit'];
const travelSpan = exitStart - routingStart;
const t = (elapsedMs - routingStart) / travelSpan;
@@ -132,7 +203,6 @@ export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose {
isSettled = elapsedMs >= total;
}
} else {
// C / D: slide down the chute, then settle on the cage floor.
const chuteName: SurfaceName = category === 'C' ? 'chute_c' : 'chute_d';
const cageName: SurfaceName = category === 'C' ? 'c_cage_floor' : 'd_cage_floor';
const travelSpan = clearStart - routingStart;
@@ -147,14 +217,14 @@ export function getPhysicalItemPose(input: PoseInput): PhysicalItemPose {
}
}
// Guarantee no NaN/Infinity escapes.
if (!Number.isFinite(pos[0]) || !Number.isFinite(pos[1]) || !Number.isFinite(pos[2])) {
pos = [SURFACES.main_belt.start[0], SURFACES.main_belt.surfaceY + itemHeightM / 2, 0];
const j = applyJitter(pos, rotY, jitter);
if (!Number.isFinite(j.pos[0]) || !Number.isFinite(j.pos[1]) || !Number.isFinite(j.pos[2])) {
j.pos = [SURFACES.main_belt.start[0], SURFACES.main_belt.surfaceY + itemHeightM / 2, 0];
}
return {
position: pos,
rotation: [0, rotY, 0],
position: j.pos,
rotation: [0, j.rotY, 0],
surface,
phase,
isSettled,

View File

@@ -121,8 +121,9 @@ describe('Playlist Items Physical Constraints', () => {
}
});
it('playlist has 8 cases', () => {
expect(DEMO_PLAYLIST).toHaveLength(8);
it('playlist has classification and safety cases', () => {
expect(DEMO_PLAYLIST.length).toBeGreaterThanOrEqual(8);
expect(DEMO_PLAYLIST).toHaveLength(10);
});
it('low confidence case still has B/C/D category', () => {

View File

@@ -0,0 +1,30 @@
/**
* Quality mode unit tests.
*/
import { describe, it, expect } from 'vitest';
import { detectQualityMode, getQualitySettings, adaptQuality } from './qualityMode';
describe('qualityMode', () => {
it('selects low on narrow viewports', () => {
expect(detectQualityMode(600)).toBe('low');
});
it('selects demo on wide viewports with healthy FPS', () => {
expect(detectQualityMode(1440, 60)).toBe('demo');
});
it('downgrades when FPS collapses', () => {
expect(detectQualityMode(1440, 20)).toBe('low');
});
it('demo preset keeps effects off for stable FPS', () => {
const s = getQualitySettings('demo');
expect(s.effectsEnabled).toBe(false);
expect(s.shadows).toBe(false);
expect(s.targetFps).toBe(60);
});
it('adaptQuality steps down under load', () => {
expect(adaptQuality('demo', 20)).toBe('high');
});
});

85
src/domain/qualityMode.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Render quality modes adapted to device capability and demo needs.
* Demo/Ultra targets stable 60 FPS when hardware allows; falls back gracefully.
*/
export type QualityMode = 'low' | 'medium' | 'high' | 'demo';
export interface QualitySettings {
mode: QualityMode;
dprMax: number;
antialias: boolean;
shadows: boolean;
maxVisibleItems: number;
effectsEnabled: boolean;
rollerDetail: 'none' | 'sparse' | 'full';
targetFps: 30 | 60;
}
const PRESETS: Record<QualityMode, QualitySettings> = {
low: {
mode: 'low',
dprMax: 1,
antialias: false,
shadows: false,
maxVisibleItems: 3,
effectsEnabled: false,
rollerDetail: 'none',
targetFps: 30,
},
medium: {
mode: 'medium',
dprMax: 1.25,
antialias: false,
shadows: false,
maxVisibleItems: 5,
effectsEnabled: false,
rollerDetail: 'sparse',
targetFps: 30,
},
high: {
mode: 'high',
dprMax: 1.5,
antialias: true,
shadows: false,
maxVisibleItems: 6,
effectsEnabled: false,
rollerDetail: 'full',
targetFps: 60,
},
demo: {
mode: 'demo',
dprMax: 1.5,
antialias: true,
shadows: false,
maxVisibleItems: 6,
effectsEnabled: false,
rollerDetail: 'full',
targetFps: 60,
},
};
/** Auto-select quality from viewport width and optional FPS sample. */
export function detectQualityMode(width: number, recentFps?: number): QualityMode {
if (width < 768) return 'low';
if (width < 1100) return 'medium';
if (recentFps !== undefined && recentFps < 28) return 'low';
if (recentFps !== undefined && recentFps < 45) return 'medium';
return 'demo';
}
export function getQualitySettings(mode: QualityMode): QualitySettings {
return PRESETS[mode];
}
/** Soft downgrade when FPS drops below target. */
export function adaptQuality(current: QualityMode, fps: number): QualityMode {
const order: QualityMode[] = ['low', 'medium', 'high', 'demo'];
const idx = order.indexOf(current);
const target = getQualitySettings(current).targetFps;
if (fps < target * 0.7 && idx > 0) return order[idx - 1];
if (fps > target * 0.95 && idx < order.length - 1 && current !== 'demo') {
return order[Math.min(idx + 1, order.length - 1)];
}
return current;
}

20
src/domain/seededRng.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Deterministic PRNG (Mulberry32) for reproducible demo variability.
*/
export function createSeededRng(seed: number): () => number {
let t = seed >>> 0;
return () => {
t += 0x6d2b79f5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
/** Small signed offset in [-amplitude, +amplitude]. */
export function seededOffset(rng: () => number, amplitude: number): number {
return (rng() * 2 - 1) * amplitude;
}
export const DEFAULT_DEMO_SEED = 20260715;

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { createSimulation, stepSimulationToNextState, setRunning, stepSimulation } from './simulation';
import { SCENARIOS } from '../data/scenarios';
function scenario(id: string) {
const s = SCENARIOS.find((x) => x.id === id);
if (!s) throw new Error(`missing scenario ${id}`);
return s;
}
describe('simulation state machine', () => {
it('classifies and routes normal item to B/C/D via classifyItem', () => {
let state = createSimulation(scenario('normal_flow'));
state = stepSimulationToNextState(state);
expect(state.machineState).toBe('MOVING_TO_CAMERA');
expect(state.currentItem).toBeDefined();
expect(['B', 'C', 'D']).toContain(state.currentItem!.classification.category);
});
it('oversized scenario routes to C', () => {
let state = createSimulation(scenario('oversized_item'));
state = stepSimulationToNextState(state);
expect(state.currentItem!.classification.category).toBe('C');
});
it('c_priority chooses C over D', () => {
let state = createSimulation(scenario('c_priority'));
state = stepSimulationToNextState(state);
expect(state.currentItem!.classification.category).toBe('C');
expect(state.currentItem!.item.roundness).toBeGreaterThanOrEqual(0.7);
});
it('jam scenario enters FAULT', () => {
let state = setRunning(createSimulation(scenario('jam')), true);
state = setRunning(stepSimulationToNextState(state), true);
for (let i = 0; i < 120; i++) {
state = stepSimulation(setRunning(state, true), 100);
if (state.machineState === 'FAULT') break;
}
expect(state.machineState).toBe('FAULT');
expect(state.systemStatus).toBe('FAULT');
});
it('emergency_stop freezes system', () => {
let state = setRunning(createSimulation(scenario('emergency_stop')), true);
state = stepSimulationToNextState(state);
for (let i = 0; i < 40; i++) {
state = stepSimulation(state, 100);
if (state.machineState === 'EMERGENCY_STOP') break;
}
expect(state.machineState).toBe('EMERGENCY_STOP');
expect(state.systemStatus).toBe('EMERGENCY_STOP');
});
});

View File

@@ -1,13 +1,14 @@
import { lazy, Suspense, useEffect, useState, useMemo } from 'react';
import { lazy, Suspense, useEffect, useState, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import ThreeFallback from '../components/ThreeD/ThreeFallback';
import ThreeErrorBoundary from '../components/ThreeD/ThreeErrorBoundary';
import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebGL';
import { DEMO_PLAYLIST, PLAYLIST_LENGTH } from '../domain/demoPlaylist';
import type { ContinuousPlaybackState } from '../domain/continuousPlayback';
import { getCaseProgress, getCurrentPhaseConfig } from '../domain/continuousPlayback';
import type { ContinuousPlaybackState, PlaybackSpeed } from '../domain/continuousPlayback';
import { getCaseProgress, getCurrentPhaseConfig, isFaultActive } from '../domain/continuousPlayback';
import { getMeasurementData, shouldShowMeasurement } from '../domain/measurementSystem';
import { getViewportType, type ViewportType } from '../domain/cinematicCamera';
import { detectQualityMode } from '../domain/qualityMode';
import { resolveItem } from '../data/resolveItem';
import CVInspectionOverlay from '../components/CVInspectionOverlay';
const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous'));
@@ -17,18 +18,30 @@ interface MainPageProps {
onPlay: () => void;
onPause: () => void;
onStop: () => void;
onSeekCase: (index: number) => void;
onSeekNext: () => void;
onSeekPrev: () => void;
onSetSpeed: (speed: PlaybackSpeed) => void;
}
const SPEEDS: PlaybackSpeed[] = [0.5, 1, 1.5, 2];
export default function MainPage({
playback,
onPlay,
onPause,
onStop,
onSeekCase,
onSeekNext,
onSeekPrev,
onSetSpeed,
}: MainPageProps) {
const webgl = useWebGLSupport();
const [width, setWidth] = useState(() => (typeof window === 'undefined' ? 1200 : window.innerWidth));
const [contextLost, setContextLost] = useState(false);
const [autoCameraEnabled, setAutoCameraEnabled] = useState(true);
const [presentationMode, setPresentationMode] = useState(false);
const [showEventLog, setShowEventLog] = useState(false);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
@@ -36,34 +49,98 @@ export default function MainPage({
return () => window.removeEventListener('resize', onResize);
}, []);
// Hotkeys for live demo resilience
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
switch (e.key.toLowerCase()) {
case ' ':
e.preventDefault();
if (playback.status === 'running') onPause();
else onPlay();
break;
case 'r':
onStop();
onPlay();
break;
case 'n':
case 'arrowright':
onSeekNext();
break;
case 'b':
case 'arrowleft':
onSeekPrev();
break;
case 'f':
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => undefined);
} else {
document.exitFullscreen?.().catch(() => undefined);
}
break;
case 'p':
setPresentationMode((v) => !v);
break;
case 'e':
setShowEventLog((v) => !v);
break;
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case '0': {
const idx = e.key === '0' ? 9 : Number(e.key) - 1;
if (idx < PLAYLIST_LENGTH) onSeekCase(idx);
break;
}
default:
break;
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [playback.status, onPlay, onPause, onStop, onSeekNext, onSeekPrev, onSeekCase]);
const show3D = prefer3DByDefault(width, webgl && !contextLost);
const simplified = width < 900;
const qualityMode = detectQualityMode(width);
const simplified = width < 900 || qualityMode === 'low';
const viewportType: ViewportType = getViewportType(width);
const isRunning = playback.status === 'running';
const isPaused = playback.status === 'paused';
const isFinished = playback.status === 'finished';
const faultActive = isFaultActive(playback);
const currentCase = playback.currentCase;
const category = playback.targetCategory;
const command = playback.command;
const phaseConfig = getCurrentPhaseConfig(playback);
const caseProgress = getCaseProgress(playback);
const measurementData = useMemo(() => getMeasurementData(playback), [playback]);
const showMeasurement = shouldShowMeasurement(playback.currentPhase) && (isRunning || isPaused) && !isFinished;
const showMeasurement =
!presentationMode &&
shouldShowMeasurement(playback.currentPhase) &&
(isRunning || isPaused) &&
!isFinished;
const handlePlayPause = () => {
if (isRunning) {
onPause();
} else {
onPlay();
}
if (isRunning) onPause();
else onPlay();
};
const enterFullscreen = useCallback(() => {
document.documentElement.requestFullscreen?.().catch(() => undefined);
setPresentationMode(true);
}, []);
return (
<div className="main-page">
{/* Full-screen 3D Demo */}
<div className={`main-page ${presentationMode ? 'presentation-mode' : ''} ${faultActive ? 'fault-active' : ''}`}>
<div className="main-demo-viewport">
{show3D ? (
<ThreeErrorBoundary
@@ -93,70 +170,86 @@ export default function MainPage({
)}
</div>
{/* Measurement System Overlay - right center */}
{width >= 768 && (
<CVInspectionOverlay data={measurementData} visible={showMeasurement} />
)}
{/* Minimal HUD - top right */}
<div className="main-hud">
<div className="hud-row">
<span className="hud-label">Item</span>
<span className="hud-value">{currentCase.title}</span>
</div>
<div className="hud-row">
<span className="hud-label">Status</span>
<span className={`hud-value status-${playback.status}`}>
{isFinished ? 'FINISHED' : phaseConfig.label}
</span>
</div>
<div className="hud-row">
<span className="hud-label">Category</span>
<span className={`hud-value ${category ? `category-${category}` : ''}`}>
{category ?? '—'}
</span>
</div>
<div className="hud-row">
<span className="hud-label">Command</span>
<span className="hud-value">{command}</span>
</div>
<div className="hud-row">
<span className="hud-label">Speed</span>
<span className="hud-value">1.0 m/s</span>
</div>
{playback.warning && (
<div className="hud-row hud-warning">
<span className="hud-value warning-text">⚠ {playback.warning}</span>
{!presentationMode && (
<div className="main-hud">
<div className="hud-row">
<span className="hud-label">Item</span>
<span className="hud-value">{currentCase.title}</span>
</div>
<div className="hud-row">
<span className="hud-label">Status</span>
<span className={`hud-value status-${playback.status} ${faultActive ? 'status-fault' : ''}`}>
{isFinished ? 'FINISHED' : phaseConfig.label}
</span>
</div>
<div className="hud-row">
<span className="hud-label">Category</span>
<span className={`hud-value ${category ? `category-${category}` : ''}`}>
{category ?? '—'}
</span>
</div>
<div className="hud-row">
<span className="hud-label">Command</span>
<span className="hud-value">{command}</span>
</div>
{playback.classification && (
<div className="hud-row hud-row-small">
<span className="hud-value proof-text">
{playback.classification.dimensionsPass ? 'DIM✓' : 'DIM✗'} · K=
{resolveItem(currentCase.itemId).roundness.toFixed(2)} · {playback.classification.reason}
</span>
</div>
)}
<div className="hud-row">
<span className="hud-label">Speed</span>
<span className="hud-value">{playback.speed.toFixed(1)}× · 1.0 m/s</span>
</div>
{playback.warning && (
<div className="hud-row hud-warning">
<span className="hud-value warning-text">⚠ {playback.warning}</span>
</div>
)}
<div className="hud-divider" />
<div className="hud-row">
<span className="hud-label">Case</span>
<span className="hud-value">{playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH}</span>
</div>
<div className="hud-row hud-row-small">
<span className="hud-value">{currentCase.description}</span>
</div>
)}
<div className="hud-divider" />
<div className="hud-row">
<span className="hud-label">Case</span>
<span className="hud-value">{playback.currentCaseIndex + 1}/{PLAYLIST_LENGTH}</span>
</div>
<div className="hud-row hud-row-small">
<span className="hud-value">{currentCase.description}</span>
</div>
</div>
)}
{/* Case progress bar */}
<div className="main-case-progress">
<div className="case-progress-bar" style={{ width: `${caseProgress * 100}%` }} />
<div
className={`case-progress-bar ${faultActive ? 'fault' : ''}`}
style={{ width: `${caseProgress * 100}%` }}
/>
</div>
{/* Playlist progress dots */}
<div className="main-progress">
{DEMO_PLAYLIST.map((c, idx) => (
<div
key={c.id}
className={`progress-dot ${idx === playback.currentCaseIndex ? 'active' : ''} ${idx < playback.currentCaseIndex ? 'done' : ''}`}
title={`${idx + 1}. ${c.title} → ${c.expectedCategory}`}
/>
))}
</div>
{!presentationMode && (
<div className="main-progress">
{DEMO_PLAYLIST.map((c, idx) => (
<button
key={c.id}
type="button"
className={`progress-dot ${idx === playback.currentCaseIndex ? 'active' : ''} ${idx < playback.currentCaseIndex ? 'done' : ''} ${c.faultType ? 'fault-dot' : ''}`}
title={`${idx + 1}. ${c.title} → ${c.faultType ?? c.expectedCategory}`}
onClick={() => onSeekCase(idx)}
aria-label={`Jump to case ${idx + 1}`}
/>
))}
</div>
)}
{/* Play/Pause Button - center bottom */}
<div className="main-controls">
<button type="button" className="ctrl-nav" onClick={onSeekPrev} aria-label="Previous case" title="Prev (← / B)">
⏮
</button>
<button
type="button"
className="play-button"
@@ -172,20 +265,37 @@ export default function MainPage({
{isRunning ? 'Pause' : isPaused ? 'Resume' : isFinished ? 'Replay' : 'Play Demo'}
</span>
</button>
<button type="button" className="ctrl-nav" onClick={onSeekNext} aria-label="Next case" title="Next (→ / N)">
⏭
</button>
{(isRunning || isPaused) && (
<button type="button" className="stop-button" onClick={onStop} aria-label="Stop demo">
<button type="button" className="stop-button" onClick={onStop} aria-label="Stop demo" title="Reset (R)">
⏹
</button>
)}
{!presentationMode && (
<div className="speed-controls" role="group" aria-label="Playback speed">
{SPEEDS.map((s) => (
<button
key={s}
type="button"
className={`speed-btn ${playback.speed === s ? 'active' : ''}`}
onClick={() => onSetSpeed(s)}
>
{s}×
</button>
))}
</div>
)}
</div>
{/* Finished overlay */}
{isFinished && (
<div className="main-finished-overlay">
<div className="finished-content">
<h2>Demo Complete</h2>
<p>All 8 cases demonstrated successfully</p>
<p>All {PLAYLIST_LENGTH} cases demonstrated — including safety scenarios</p>
<button type="button" className="btn-primary" onClick={onPlay}>
Replay Demo
</button>
@@ -193,9 +303,22 @@ export default function MainPage({
</div>
)}
{/* Auto Camera Toggle - bottom left */}
{show3D && (
<button
{showEventLog && !presentationMode && (
<div className="main-event-log" aria-live="polite">
<div className="event-log-header">Event journal</div>
<ul>
{playback.events.slice(0, 8).map((ev) => (
<li key={ev.id} className={`evt-${ev.status}`}>
<span className="evt-time">{Math.round(ev.timestampMs / 1000)}s</span>
<span className="evt-msg">{ev.message}</span>
</li>
))}
</ul>
</div>
)}
{show3D && !presentationMode && (
<button
type="button"
className="auto-camera-toggle"
onClick={() => setAutoCameraEnabled(!autoCameraEnabled)}
@@ -206,10 +329,23 @@ export default function MainPage({
</button>
)}
{/* Details link - bottom right */}
<Link to="/details" className="details-link">
Details →
</Link>
{!presentationMode && (
<>
<button type="button" className="presentation-toggle" onClick={enterFullscreen} title="Presentation (P / F)">
Present
</button>
<Link to="/details" className="details-link">
Details →
</Link>
<div className="hotkey-hint">Space play · N/B seek · 1–0 jump · R reset · P present · E log · F fullscreen</div>
</>
)}
{presentationMode && (
<button type="button" className="presentation-exit" onClick={() => setPresentationMode(false)}>
Exit presentation (P)
</button>
)}
</div>
);
}

View File

@@ -2832,3 +2832,162 @@ button:disabled {
max-height: calc(100vh - 320px);
}
}
/* ——— Maximum demo realism: controls, presentation, events ——— */
.ctrl-nav {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(5, 9, 16, 0.7);
color: #e2e8f0;
font-size: 16px;
cursor: pointer;
}
.ctrl-nav:hover { border-color: #38bdf8; color: #38bdf8; }
.speed-controls {
display: flex;
gap: 4px;
margin-left: 8px;
padding: 4px;
border-radius: 999px;
background: rgba(5, 9, 16, 0.65);
border: 1px solid rgba(148, 163, 184, 0.25);
}
.speed-btn {
min-width: 40px;
height: 32px;
border: none;
border-radius: 999px;
background: transparent;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.speed-btn.active {
background: rgba(56, 189, 248, 0.25);
color: #7dd3fc;
}
.progress-dot {
border: none;
cursor: pointer;
padding: 0;
}
.progress-dot.fault-dot {
background: rgba(239, 68, 68, 0.55);
}
.progress-dot.fault-dot.active {
background: #ef4444;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.45);
}
.case-progress-bar.fault {
background: linear-gradient(90deg, #f97316, #ef4444);
}
.main-page.fault-active .main-hud {
border-color: rgba(239, 68, 68, 0.45);
}
.hud-value.status-fault,
.hud-value.proof-text {
color: #fda4af;
}
.hud-value.proof-text {
color: #cbd5e1;
font-size: 11px;
line-height: 1.35;
}
.presentation-toggle,
.presentation-exit {
position: absolute;
z-index: 12;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(5, 9, 16, 0.75);
color: #7dd3fc;
font-weight: 700;
cursor: pointer;
}
.presentation-toggle { bottom: 16px; right: 140px; }
.presentation-exit { top: 16px; right: 16px; }
.hotkey-hint {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 9;
font-size: 11px;
color: rgba(148, 163, 184, 0.75);
pointer-events: none;
white-space: nowrap;
}
.main-event-log {
position: absolute;
left: 16px;
bottom: 100px;
z-index: 12;
width: min(360px, 42vw);
max-height: 240px;
overflow: auto;
padding: 12px;
border-radius: 12px;
background: rgba(5, 9, 16, 0.88);
border: 1px solid rgba(148, 163, 184, 0.25);
backdrop-filter: blur(10px);
color: #e2e8f0;
font-size: 12px;
}
.event-log-header {
font-weight: 800;
margin-bottom: 8px;
color: #7dd3fc;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 11px;
}
.main-event-log ul {
list-style: none;
margin: 0;
padding: 0;
}
.main-event-log li {
display: flex;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.evt-time { color: #64748b; min-width: 28px; }
.evt-error .evt-msg { color: #fda4af; }
.evt-warning .evt-msg { color: #fdba74; }
.evt-success .evt-msg { color: #86efac; }
.cv-reason-row .cv-reason {
font-size: 11px;
line-height: 1.3;
color: #cbd5e1;
white-space: normal;
}
.main-page.presentation-mode .main-hud,
.main-page.presentation-mode .main-progress,
.main-page.presentation-mode .auto-camera-toggle,
.main-page.presentation-mode .details-link,
.main-page.presentation-mode .hotkey-hint,
.main-page.presentation-mode .presentation-toggle,
.main-page.presentation-mode .speed-controls {
display: none;
}
@media (max-width: 900px) {
.speed-controls { display: none; }
.hotkey-hint { display: none; }
.presentation-toggle { right: 16px; bottom: 72px; }
}