REFACTORING.md 41,9 КБ
Newer Older
1
# Рефакторинг и архитектура kg2cytoscape
chainreaction's avatar
chainreaction включено в состав коммита
2

chainreaction's avatar
chainreaction включено в состав коммита
3
> Обзор — [**README.md**](README.md), техника — [**AGENTS.md**](AGENTS.md),
4
5
6
7
8
9
10
11
12
13
14
> схема колонок — [**SCHEMA.md**](SCHEMA.md), история — [**CHANGELOG.md**](CHANGELOG.md),
> планы — [**ROADMAP.md**](ROADMAP.md).

Документ содержит три части: **выполненный** рефакторинг (разбиение на модули
L0–L5), **проектируемый** архитектурный рефакторинг (настраиваемая визуализация
предметной таксономии памяти) и **план внедрения** последнего на ближайшее
время.

---

# Часть 1. Выполненный рефакторинг (L0–L5)
15

chainreaction's avatar
chainreaction включено в состав коммита
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
## Цель

Повысить читаемость кода, применив:
1. юнит-тестирование;
2. классификацию сущностей по уровням абстракции;
3. разбиение на модули;
4. функциональный подход (чистые функции + изоляция побочных эффектов);
5. рациональное именование сущностей;
6. подробное комментирование;
7. описание логики и назначения в `README.md`;
8. отчёт о выполнении по каждому шагу (ниже, в разделе «Отчёты»).

**Важно:** тесты пишутся **до** начала разбиения на модули (TDD).

## Целевая архитектура (уровни абстракции)

| Уровень | Слой | Модули | Ответственность |
|---|---|---|---|
| L0 | core | `src/core/text.mjs`, `src/core/escape.mjs`, `src/core/log.mjs` | примитивы: обрезка строк, даты, экранирование аргументов CyREST, логирование |
35
| L1 | parse | `src/parse/records.mjs`, `src/parse/parse.mjs`, `src/parse/sources.mjs`, `src/parse/sources/*.mjs` | разбор входа в сырой граф; адаптеры источников (`memory`, `neo4j`, `obsidian`, `lorebook`, `generic`) |
36
| L2 | graph | `src/graph/model.mjs`, `src/graph/config.mjs`, `src/graph/geometry.mjs` | модель графа (степени, размеры), визуальный язык из конфига, геометрия раскладки |
chainreaction's avatar
chainreaction включено в состав коммита
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| L3 | cyrest | `src/cyrest/client.mjs`, `src/cyrest/columns.mjs`, `src/cyrest/style.mjs` | транспорт CyREST, таблицы атрибутов, стиль |
| L4 | usecases | `src/usecases/build.mjs`, `src/usecases/sync.mjs`, `src/usecases/watch.mjs` | сценарии: полная сборка, инкремент, слежение |
| L5 | cli | `src/cli/args.mjs`, `kg2cytoscape.mjs` | разбор аргументов и точка входа |

## План (шаги и критерии готовности)

1. **План + тестовая инфраструктура.** Создать `REFACTORING.md` (этот файл) и `package.json` со скриптом `npm test` (встроенный `node:test`).
2. **Тесты до разбиения (red).** Спроектировать интерфейсы модулей и написать юнит-тесты в `test/`, импортирующие будущие модули. Прогон должен падать (`ERR_MODULE_NOT_FOUND`) — это фиксирует TDD-порядок.
3. **L0 core.** Извлечь `text`, `escape`, `log`; тесты green.
4. **L1 parse.** Извлечь `records` и `parse`; тесты green.
5. **L2 graph.** Извлечь `model`, `colors`, `geometry`; тесты green.
6. **L3 cyrest.** Извлечь `client`, `columns`, `style`; тесты green.
7. **L4 usecases.** Извлечь `build`, `sync`, `watch`; тесты green.
8. **L5 cli.** Тонкая точка входа `kg2cytoscape.mjs` + `src/cli/args.mjs`; полный прогон тестов.
9. **README.** Описать назначение, логику и карту модулей.
52
10. **Финальная проверка.** `npm test`, `node kg2cytoscape.mjs example.jsonl --dry-run`, отчёт.
chainreaction's avatar
chainreaction включено в состав коммита
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

## Отчёты о выполнении

### Шаг 1. План + тестовая инфраструктура
- Создан `REFACTORING.md` (план и отчёты).
- Создан `package.json` (`type: module`, `scripts.test = node --test test/`).
- **Итог:** инфраструктура тестов готова, зависимостей нет (только встроенный `node:test`).

### Шаг 2. Тесты до разбиения (red)
- Написаны тесты `test/*.test.mjs` под интерфейсы будущих модулей (text, escape, colors, args, records, parse, model, geometry, style, client).
- **Итог:** тесты падают с `ERR_MODULE_NOT_FOUND` — модулей ещё нет, что подтверждает порядок «сначала тесты».

### Шаг 3. L0 core
- Извлечены `src/core/text.mjs` (`clip`, `short`, `obsDate`), `src/core/escape.mjs` (`escapeValue`, `escapeId`), `src/core/log.mjs`.
- **Итог:** тесты `text`, `escape` — green.

### Шаг 4. L1 parse
- Извлечены `src/parse/records.mjs` (`fromMemoryRecord`, `fromGenericRecord`, `DATE_RE`) и `src/parse/parse.mjs` (`parseRecords`, `parseFile`).
- **Итог:** тесты `records`, `parse` — green.

73
74
75
76
77
78
79
80
81
82
83
### Дополнение L1: адаптеры источников
- Разбор входа вынесен в реестр адаптеров: `src/parse/sources.mjs` +
  `src/parse/sources/*.mjs` с интерфейсом `{ name, detect, parse }` (для
  файловых источников — `detectFile` / `parseInput`).
- Источники: `memory` (родной JSONL), `neo4j` (экспорт `{nodes, relationships}`),
  `obsidian` (Markdown + wikilinks), `lorebook` (SQLite темпорального графа),
  `generic` (запасной).
- Новый формат = новый файл + строка в реестре `SOURCES`; существующие
  адаптеры не меняются. Выбор — опция `--source` или автодетекция.
- **Итог:** тесты `sources` — green.

chainreaction's avatar
chainreaction включено в состав коммита
84
### Шаг 5. L2 graph
85
- Извлечены `src/graph/model.mjs` (`dedupeEdges`, `computeDegrees`, `nodeSize`, `finalizeGraph`), `src/graph/config.mjs` (визуальный язык: дефолты, `loadConfig`, `assignColors`, `nodeVisual`), `src/graph/geometry.mjs` (`placeNear`, `placeOutside`, `clusterCircles`, `spreadClusters`).
chainreaction's avatar
chainreaction включено в состав коммита
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
- **Итог:** тесты `model`, `colors`, `geometry` — green.

### Шаг 6. L3 cyrest
- Извлечены `src/cyrest/client.mjs` (транспорт + чтение представлений/рёбер), `src/cyrest/columns.mjs` (`NODE_COLS`, `EDGE_COLS`, `ensureColumns`, `setAttrs`), `src/cyrest/style.mjs` (`buildStyleDefaults`, `buildStyleMappings`, `ensureStyle`, `SINGLETON`, `parseClusterLabels`).
- **Итог:** тесты `client`, `style` — green.

### Шаг 7. L4 usecases
- Извлечены `src/usecases/build.mjs` (`buildNetwork`), `src/usecases/sync.mjs` (`syncNetwork`, `spreadNewClusters`), `src/usecases/watch.mjs` (`watchLoop`, `fileStamp`).
- Глобальные переменные заменены объектом опций `opt` и клиентом CyREST.
- **Итог:** логика сценариев сохранена 1:1, зависимости явные.

### Шаг 8. L5 cli
- Точка входа `kg2cytoscape.mjs` стала тонким оркестратором; разбор аргументов — `src/cli/args.mjs` (`parseArgs`, `buildOptions`).
- **Итог:** `npm test` — все тесты green.

### Шаг 9. README
- В `README.md` добавлены разделы «Архитектура и модули» (таблица уровней абстракции, поток данных) и «Тестирование»; в «Сопутствующие файлы» добавлены `src/`, `test/`, `package.json`.
103
- В разделе «Формат файла памяти» зафиксировано, что файл памяти создаётся эталонной реализацией сервера памяти Anthropic (Knowledge Graph Memory MCP Server). То же пояснение добавлено в `AGENTS.md`.
chainreaction's avatar
chainreaction включено в состав коммита
104
105
106
107
- **Итог:** логика и назначение кода задокументированы.

### Шаг 10. Финальная проверка
- `npm test` — 59 тестов, все проходят.
108
- `node kg2cytoscape.mjs example.jsonl --dry-run` — разбор работает без Cytoscape.
chainreaction's avatar
chainreaction включено в состав коммита
109
- **Итог:** рефакторинг завершён, поведение CLI сохранено.
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580

---

# Часть 2. Архитектурный рефакторинг: настраиваемая таксономия памяти

> Статус: архитектурное предложение, без реализации кода.
> Задание — [**ARCHITECT_TASK.md**](ARCHITECT_TASK.md).

## 2.1 Назначение и рамка

Программа — визуализатор графа памяти для **широкого круга пользователей**,
каждый со своей парадигмой работы и своей предметной таксономией. Поэтому
главный принцип:

> **Все графы — чужие.**

Не существует «нашего» формата, которому доверяем больше, чем остальным.
Собственный файл памяти — частный случай чужого, а не особый язык со скрытой
структурой. Из этого следует: визуализатор **не выводит структуру из имён**,
**не меняет исходные данные** и строит представление поверх любых входных
значений, а не подгоняет их под себя.

## 2.2 Что даёт эталонная память Anthropic

Эталонная реализация (`@modelcontextprotocol/server-memory`) накладывает
минимум ограничений и этим определяет контракт входных данных:

| Поле записи | Назначение | Ограничения |
|---|---|---|
| `name` | уникальный и **стабильный** идентификатор сущности | произвольная строка; переименования нет |
| `entityType` | тип сущности (примеры `person`, `organization`, `event`) | произвольная строка, **словарь не ограничен** |
| `observations` | атомарные наблюдения об entity | массив строк; добавляются/удаляются независимо |
| `relationType` | тип направленной связи | произвольная строка, **в активном залоге** |

Отношения задаются явной записью `{from, to, relationType}`. Наблюдения
прикреплены к сущности самой записью entity.

**Ключевое следствие:** память уже допускает произвольную предметную
таксономию (`supplier`, `offer`, `game` — легитимные `entityType`). Ограничение
словаря существует только в визуализаторе, и его надо устранить, а не
перекладывать на память.

Стабильность `name` — критичное свойство для синхронизации: «та же сущность»
распознаётся по имени, а расхождение содержимого означает «изменилась» или
«появилась/исчезла». Парсинг имени для этого не требуется.

## 2.3 Принцип разделения: исходная таксономия vs визуальный класс

Сегодня две разные вещи склеены в одной колонке `kg_type` и одном словаре
`config.entities`:

1. **Семантика памяти** — исходный `entityType`, пишется в `kg_type` и больше
   не меняется.
2. **Визуальный класс** — он же `kg_type` используется как ключ стиля
   (`entityStyle`/`nodeVisual`/`sizeOf`).

Проблема: имя визуального стиля совпадает с исходным типом. Незнакомый тип
(`supplier`, `offer`) не находит себя в `entities`, получает случайный цвет из
палитры, и семантическая категория теряется.

Решение — ввести отдельное понятие **визуальный класс** и слой классификатора:

| Понятие | Хранение | Назначение | Кто меняет |
|---|---|---|---|
| исходный `entityType` | `kg_type` | **семантика памяти**, неизменна | `parse` |
| исходное `name` | `id` узла | идентичность, неизменна | `parse` |
| **визуальный класс** | **`kg_vclass`** | ключ стиля (цвет/форма/размер) | **классификатор** |

Стиль привязан к `kg_vclass`, а не к `kg_type`. Тултип показывает оба.
Узел `supplier` остаётся с `kg_type:"supplier"`, но получает `kg_vclass`
по правилам (или `unknown`). Исходные данные не переименовываются и не
теряются никогда.

## 2.4 Характер изменения: рефакторинг

Это **не новое приложение и не проект с нуля**, а рефакторинг существующего
пайплайна: каркас и слои L0–L5 остаются, меняются лишь точки встраивания.

- поток `parse → model → config → build/sync` сохраняется целиком;
- классификатор **встраивается** между `parse` и `model` (точка
  `finalizeGraph`), а не создаёт новый конвейер;
- `kg_vclass`/`kg_status` — добавление колонок к живой схеме `kg_*`;
- `config.entities → classes + rules` — эволюция одного словаря, а не новый
  формат (обратная совместимость через identity-правила);
- `sync.mjs` расширяется с «только добавить» на diff S1–S5.

Точки правки:

| Файл / слой | Что происходит |
|---|---|
| `src/parse/records.mjs`, `sources/*` | **без правок** — исходные `name`/`entityType` уже сохраняются в `kg_type` |
| `src/graph/classifier.mjs` | **новый модуль** (уровень L2) |
| `src/graph/config.mjs` | `entities → classes + rules`; `entityStyle`/`nodeVisual`/`sizeOf` читают `kg_vclass` |
| `src/graph/model.mjs` | подключить `classify`, проставить `kg_vclass` в `finalizeGraph` |
| `src/cyrest/columns.mjs` | добавить `kg_vclass`, `kg_status`, `kg_locked` в `NODE_COLS` |
| `src/usecases/build.mjs` | цвета считать от классов, писать `kg_vclass` |
| `src/usecases/sync.mjs` | diff S1–S5, ghost-маркер, флаг `--prune-ghost` |
| `test/` | добавить `classifier.test.mjs` (TDD: RED → GREEN) |

## 2.5 Место классификации в потоке

```mermaid
flowchart LR
  file["memory.jsonl"] -->|parse| raw["Сырой граф<br/>name, kg_type = исходный entityType,<br/>kg_ns, kg_parent, kg_date, kg_text"]
  raw -->|classifier| cls["+ kg_vclass<br/>визуальный класс"]
  cls -->|semantic| sem["(+ kg_semantic, опционально)"]
  sem -->|model| model["Модель<br/>+ kg_size, kg_degree, kg_seq, kg_tip"]
  model -->|config| color["Визуальный язык<br/>+ kg_fill, kg_shape, kg_border, kg_alpha<br/>по kg_vclass"]
  color -->|build/sync| cy["Cytoscape<br/>колонки kg_*"]
  cy -->|passthrough| vp["NODE_FILL_COLOR, NODE_SIZE, …"]
```

Новый чистый модуль **`src/graph/classifier.mjs`** (уровень L2):

- `classify(node, config) → vclass` — чистая функция;
- `compileRules(config)` — валидация и предкомпиляция правил (regex);
- `assignClassColors(config, classes, existing)` — цвета по классам с
  сохранением уже показанных (для `--update`/`--watch`).

## 2.6 Двухфазная модель: подготовка и кураторство

Ранняя попытка свести всё в одну «иерархию силы» смешивала несопоставимое.
Правильная модель — **две фазы**, связанные одним правилом границы.

### Фаза A — подготовка (наш код)

Даёт каждому узлу гарантированный стартовый облик:

1. базовый нейтральный облик (fallback);
2. статическая классификация (`kg_vclass`);
3. семантическая метка (опционально, поверх).

Внутри фазы A приоритет однозначен: `семантика > статика > unknown`.

### Фаза B — кураторство (Cytoscape + AutoAnnotate)

Пользователь двигает узлы, красит, группирует, правит кластеры средствами
Cytoscape и AutoAnnotate. Это **не правило классификации**, а пост-фактум
редактирование готовой сети — отдельная фаза, отдельный владелец.

Ручной слой **не участвует** в «иерархии силы»: он не конкурирует со
статической классификацией за один и тот же узел в один момент. Статика
отрабатывает раньше, ручное — позже.

### Правило границы между фазами

> Фаза A может трогать только то, что ещё не тронуто фазой B. Всё, что
> пользователь изменил в Cytoscape/AA, считается финальным и при
> `--update`/`--watch` не пересчитывается.

Приоритет ручного реализуется не «кто сильнее», а **блокировкой поля**
(`kg_locked`): рука ставит блокировку, и даже изменённая сущность не
перекрашивается, пока пользователь не разблокирует.

## 2.7 Классификация — центральный примитив

Центр программы — классификация, два независимых уровня; оба не переписывают
исходные данные.

### Уровень 1 — статическая (скрытая структура, паттерны)

Детерминированная, воспроизводимая, мгновенная. По каждой неструктурированной
строке (`name`, `entityType`, текст `observations`, `relationType`) выявляется
скрытая форма и по ней присваивается визуальный класс. Это не подстроковый
grep, а **статический анализ без ИИ и без пользователя**:

- регулярные выражения описывают **форму строки** (паттерн) целиком, а не
  ищут подстроку — якоря `^…$` опознают «выглядит как неймспейс `project:…`»,
  «выглядит как дата», «выглядит как устаревший тип с опечаткой»;
- из неё извлекается **скрытая структура** (сегменты, префиксы, конвенции
  именования) и **частичная семантика** (категория, к которой строка
  статистически принадлежит);
- результат — визуальный класс (`kg_vclass`), не переименование;
- несовпадение → `unknown` (нейтральный стиль), узел не теряется;
- приоритет в пределах уровня: точное значение (`eq`) > структура > паттерн
  (regex), first-match-wins.

### Уровень 2 — семантическая (человек или другой ИИ)

Присвоение смысла, выходящего за выразимость статического анализа. Два
поставщика:

- **пользователь** — точечные метки/группы вручную (кураторство);
- **другой ИИ** — отдельный проход (LLM/эмбеддинг-кластеризация), опционально.

Семантический слой не смешивается со статическим: он пишет в свою колонку
(`kg_semantic`), применяется отдельным проходом и переопределяет только
визуал, никогда не меняя исходные `name`/`entityType`.

Уровни последовательны: сначала статика даёт гарантированный визуал каждому
узлу (включая `unknown`), затем семантика уточняет поверх. Сбой или отсутствие
семантики не оставляет узел без облика.

## 2.8 Неймспейс — не примитив структуры

Неймспейс (`kg_ns`) и родитель (`kg_parent`) сейчас выводятся из имени
эвристиками `nsOf`/`parentOf`. Для чужих графов это порождает ложную
структуру: `"PostgreSQL:tuning:index"` трактуется как три уровня подчинённости
и создаёт ложный `kg_parent`, ложные кластеры AutoAnnotate, ложные тултипы.

Решение:

- **не выводить иерархию из имени** — группировка берётся из **явных
  отношений** (`relation`) и привязки наблюдений (`has_observation`), а не из
  строки;
- **для чужого графа** `kg_ns = default` и не участвует в структуре;
- неймспейс остаётся **опциональным** полем/фильтром для тех, кто сам
  соблюдает конвенцию двоеточий, но не опорой, на которой стоит классификатор.

Основной контракт классификатора — `entityType` + произвольный `name`; только
они гарантированно есть в любой памяти, согласной со спецификацией сервера.

## 2.9 Формат конфигурации

Секция `entities` эволюционирует в `classes` + `rules`. Если `rules` отсутствуют,
генерируются identity-правила из `classes`, поэтому старый конфиг работает без
изменений (это и есть «фильтр обратной совместимости» как настраиваемый этап,
а не отдельный механизм).

```jsonc
{
  "classes": {
    "project":     { "color": "#000000", "shape": "ELLIPSE",        "filled": true  },
    "file":        { "color": "#000000", "shape": "ELLIPSE",        "filled": false },
    "vendor":      { "color": "#F28E2B", "shape": "ROUND_RECTANGLE", "filled": true  },
    "commercial":  { "color": "#76B7B2", "shape": "ELLIPSE",        "filled": true  },
    "observation": { "color": null, "shape": "ROUND_RECTANGLE", "size": 18, "alpha": 150, "tint": 0.6 },
    "unknown":     { "color": null, "shape": "ELLIPSE", "filled": true }
  },

  "rules": [
    { "stage": "compat", "match": { "entityType": "^decsion$" },      "class": "decision" },
    { "match": { "entityType": "project" },                            "class": "project"  },
    { "match": { "entityType": "supplier|vendor" },                    "class": "vendor"   },
    { "match": { "entityType": "offer|order|contract" },               "class": "commercial" },
    { "match": { "entityType": "^game$", "name": "prefix:game:" },     "class": "project"  },
    { "match": { "name": "project:kg2cytoscape" }, "class": "project", "manual": true }
  ]
}
```

Поля правила:

- `match` — предикат по полям узла: `entityType`, `name`, (`kg_ns`, `kg_parent`
  — только как опциональные подсказки своего формата), `kg_text`;
- `class` — имя визуального класса из `classes`, обязано существовать;
- `stage` — `main` (по умолчанию) или `compat`;
- `priority` — число, больше = выше (по умолчанию 0);
- `manual` — `true` у точечных ручных переопределений;
- операторы поля: `eq` (точное значение или список через `|`), `pattern`
  (regex), `prefix`, `in` (массив).

## 2.10 Регулярные выражения как статический инструмент

Регулярные выражения здесь — не подстроковый поиск, а **описание формы
строки**: якоря `^…$` делают правило «строка целиком выглядит как X», а не
«содержит ли где-то X». В такой трактовке они — основной инструмент
статической классификации (уровень 1 §2.7).

Три ступени выразительности, от табличной к паттерновой:

1. точное значение/альтернативы (`eq`, `in`) — таблица категорий, осознанно
   заполненная человеком;
2. префикс (`prefix`) — описание сегмента конвенции именования (`supplier:*`);
3. regex (`pattern`) — описание скрытой структуры и паттерна целиком: формат
   неймспейса, форма даты, устаревшая строка с опечаткой.

Ограничения для **семантической** классификации:

- regex описывает **форму**, а не суть: `supplier` и `vendor` он не свяжет как
  синонимы без явного перечисления — смысловое «это то же понятие» остаётся
  на человеке или ИИ (уровень 2);
- хрупок к регистру, опечаткам, порядку сегментов — требует якорей `^…$` и
  дисциплины (иначе тихо матчит лишнее);
- рискует чрезмерной широтой (`.*`) и ReDoS на катастрофическом бэктрекинге.

Вывод: regex — главный инструмент **статической** фазы (форма, паттерн,
скрытая структура), но не способ переносить подлинную семантику — она уходит
в уровень 2 (человек/ИИ).

## 2.11 Алгоритм применения и разрешение конфликтов

Правила только вычисляют `kg_vclass`, никогда не трансформируют данные.
Для каждого узла порядок разрешения (по убыванию силы):

1. **ручное** — правило с `manual:true` (обычно точечное по `name`);
2. **специфичность** — `eq` > `prefix` > `pattern`;
3. **этап** — `compat` раньше `main`, но уступает более специфичному `main`
   (п.2 идёт раньше);
4. **приоритет** — `priority`, больше = выше;
5. **порядок объявления** — tie-break.

Итог — **first-match-wins** после сортировки. Один узел получает ровно один
класс.

## 2.12 Гарантия отображения (fallback)

Если не совпало ни одно правило — узел получает класс `unknown`. Класс
`unknown` резервируется и не может быть удалён или перекрыт широченным
правилом: проверка при загрузке гарантирует, что нейтральный путь всегда
остаётся. `kg_type` при этом исходный, узел не скрывается.

Незнакомый тип также не получает случайный цвет из «общей палитры типов» —
он получает стабильный нейтральный стиль `unknown`, а его исходный
`entityType` сохраняется в `kg_type`.

## 2.13 Обратное отображение (синхронизация) — diff, а не только добавление

Сейчас `sync.mjs` видит только «новый/есть» и не различает «изменился» и
«исчез». Нужен diff между памятью и сетью по стабильному ключу `name`.

```mermaid
flowchart LR
  mem["память"] --> diff["diff по name"]
  net["сеть CS"] --> diff
  diff --> S1["S1 новый: добавить"]
  diff --> S2["S2 совпадает: ничего"]
  diff --> S3["S3 изменён: обновить производные, НЕ двигать"]
  diff --> S4["S4 исчез: пометить ghost, НЕ удалять"]
  diff --> S5["S5 вернулся: снять ghost, позиция сохранена"]
```

| # | Состояние | Условие | Действие |
|---|---|---|---|
| S1 | новый | есть в памяти, нет в сети | добавить узел + производный визуал |
| S2 | совпадает | имя и поля-источники идентичны | ничего |
| S3 | изменён | имя совпало, источники разошлись | обновить **только производные** (класс, цвет, obs, tip), позицию не трогать |
| S4 | исчез | есть в сети, нет в памяти | пометить **ghost**, не удалять |
| S5 | вернулся | был ghost, снова в памяти | снять ghost, вернуть визуал, позиция сохранена |

Рёбра живут по тому же принципу: новое — добавить; исчезнувшее — притушить,
не удалять; изменённое не двигать.

## 2.14 Владельцы полей (кто и что меняет)

Чтобы «меняется, но не двигается» работало без сюрпризов:

| Владелец | Поля | При синхронизации |
|---|---|---|
| **Память** | `name`, `kg_type`, `kg_text`, `kg_obs`, `kg_date`, набор `relation` | источник истины, читается при diff |
| **Классификатор** | `kg_vclass`, `kg_fill`, `kg_shape`, `kg_size`, `kg_alpha`, `kg_tip` | пересчитывается **только из изменившихся входов** |
| **Пользователь/CS** | позиции, ручной визуал, группы и кластеры AA | **не трогается синхронизацией** |

Изменение сущности обновляет строки «Память + Классификатор», а строка
«Пользователь/CS» остаётся нетронутой: перекраска допустима, перестройка и
перемещение — нет.

## 2.15 Маркер исчезновения (красный квадрат)

Новая колонка **`kg_status`**: `active` (по умолчанию) / `ghost`.

Стиль по `kg_status == ghost` переопределяет форму на `RECTANGLE`, заливку и
контур на красный (наблюдение-ghost — маленький красный квадрат, чтобы
отличался от обычных наблюдений). Это **визуальный маркер, а не изменение
данных**: исходные `name`/`entityType` сохраняются, узлу можно вернуть
`active` когда угодно (S5).

## 2.16 Политика удаления

Ghost-узлы накапливаются, пока пользователь сам не решит. Удаление — только
по явной воле:

- ручное удаление в Cytoscape, или
- явный флаг-команда очистки (`--prune-ghost` / `--gc`), запускается только
  пользователем.

По умолчанию `--update`/`--watch` **никогда не удаляют** ghost-узлы и не
перестраивают кластеры из-за них. Это закрывает пункт ROADMAP о «фантомных
узлах»: фантомы — не мусор, а помеченное управляемое состояние.

## 2.17 Поведение при некорректных, конфликтующих, широких правилах

- **некорректное правило** (битый regex, `class` отсутствует в `classes`,
  пустой `match`) — **fail-safe**: исключается на этапе `compileRules` с
  предупреждением, сборка продолжается;
- **конфликтующие** (разный `class` одному узлу) — детерминированно по §2.11,
  опционально логируется «теневое» правило;
- **чрезмерно широкое** (regex без `^`/`$`, `.*`) — предупреждение с
  рекомендацией якорей; не запрещаем жёстко, но `unknown` остаётся последним
  рубежом;
- **пустой `rules`** — identity-автогенерация, воспроизводит текущее поведение.

## 2.18 Стабильность инкрементального режима

Классификатор обязан быть **чистым и детерминированным** (тот же узел + тот же
конфиг → тот же класс), иначе старые узлы перекрашиваются при каждом `--update`
и нарушается священное правило «не трогать существующие».

- `assignClassColors(..., existing)` сохраняет цвет уже показанных классов,
  новым выдаёт свободные цвета — старые узлы не перекрашиваются;
- новый узел незнакомого типа → `unknown` → стабильный нейтральный;
- `observations` как предикат ломает детерминизм (наблюдения меняются во
  времени) — допустим, но с явным предупреждением;
- изменение `rules` влечёт перекрашивание — документируется как ожидаемое;
  при неизменном конфиге поведение идемпотентно между запусками.

## 2.19 Компромиссы и ограничения

- **Один узел — один класс.** Мультиклассификация невыразима ради пасстроу
  (одна колонка → одно визуальное свойство).
- **Regex описывает форму, не смысл.** Расширение рубрик требует табличных
  правил; подлинная семантика — в уровне 2 (человек/ИИ).
- **Миграция конфига.** `entities` → `classes` + `rules`; старый файл
  совместим через identity-правила, богатая настройка требует явных `rules`.
- **Детерминизм важнее «умности».** Статическая классификация намеренно
  простая и предсказуемая, чтобы не ломать инкрементальную стабильность;
  подлинная семантика (NLU, эмбеддинги) сознательно вынесена в опциональный
  уровень 2.
- **Синхронизация по стабильному `name`.** Работает для памяти, согласной со
  спецификацией сервера (имя неизменно). Для источника без стабильных
  идентификаторов требуется отдельное решение — вне текущего scope.
- **Стоимость.** `O(правила × узлы)`; предкомпиляция regex в `compileRules`
  снимает накладные расходы.

---

# Часть 3. План внедрения (ближайшее время)

Внедрение идёт по TDD-порядку проекта: **тесты → RED → код → GREEN**
(`node --test`). Шаги выстроены от чистых функций (классификатор, конфиг) к
побочным эффектам (CyREST, сценарии), чтобы каждое звено проверялось
изолированно.

1. **Тесты классификатора (RED).** Написать `test/classifier.test.mjs` под
   интерфейс `classify` / `compileRules` / `assignClassColors` — тесты падают
   (`ERR_MODULE_NOT_FOUND`).

2. **Модуль `classifier.mjs` (GREEN).** Реализовать `src/graph/classifier.mjs`:
   валидация/предкомпиляция правил, first-match-wins, fallback `unknown`.

3. **Конфиг (GREEN).** В `src/graph/config.mjs` эволюция `entities → classes +
   rules`; `entityStyle`/`nodeVisual`/`sizeOf` читают визуальный класс;
   identity-правила для старого конфига. Тесты `config.test.mjs`.

4. **Модель (GREEN).** В `src/graph/model.mjs` подключить `classify` и
   проставлять `kg_vclass` в `finalizeGraph`.

5. **Колонки (GREEN).** В `src/cyrest/columns.mjs` добавить `kg_vclass`,
   `kg_status`, `kg_locked` в `NODE_COLS` (старые сети добирают через
   `ensureColumns`).

6. **Полная сборка (GREEN).** В `src/usecases/build.mjs` считать цвета от
   классов и писать `kg_vclass`.

7. **Инкремент (GREEN).** В `src/usecases/sync.mjs` внедрить diff S1–S5:
   diff по `name`, ghost-маркер `kg_status`, владельцы полей, блокировка
   `kg_locked`.

8. **CLI (GREEN).** В `src/cli/args.mjs` добавить флаг `--prune-ghost`
   (явная очистка ghost-узлов по воле пользователя).

9. **Финальная проверка.** `npm run check` (lint → format:check → typecheck →
   test), smoke `node kg2cytoscape.mjs example.jsonl --dry-run` и ручная
   визуальная проверка картинки с незнакомым типом (например `supplier`).

## Тестовые сценарии (контрольный чек-лист)

1. `name` и `kg_type` не изменяются ни при каком исходе.
2. точное сопоставление: `supplier|vendor` → `vendor`.
3. regex по имени: `prefix:game:` → заданный класс; без якорей — только задуманное.
4. first-match-wins и `priority` перебивают порядок объявления.
5. `manual:true` по `name` побеждает широкое `main`.
6. обратная совместимость: `^decsion$` → `decision`; широкий compat vs точный main → побеждает точный.
7. fallback: незнакомый тип + несовпадение → `unknown`, узел присутствует.
8. некорректное правило пропущено + предупреждение, сборка не падает.
9. `.*` → предупреждение, `unknown` достижим.
10. инкрементальная стабильность: `assignClassColors(..., existing)` сохраняет цвет.
11. `observation` остаётся системным классом, классификация его не перезаписывает.
12. пустой `rules` → identity-автогенерация воспроизводит текущее поведение.
13. diff-состояния S1–S5 (новый/совпадает/изменён/исчез/вернулся).
14. ghost: исчезнувший узел помечается, не удаляется; вернувшийся восстанавливается без сдвига.