Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion AGENT_DIARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,22 @@

---

## [2026-09-03] - Carried exp-lab-2026-01 neuro-symbolic spike artifact into main
## [2026-09-07] — Lazy-only верификация: VOR вызывается только из intel_get_project_memory, нет TTL/фона
**Status:** Open — зафиксировано как проблема + план эксперимента (10-continuous-verification.md)
**Root Cause:** По дизайну (ADR-0003) VOR ленивый, но точки вызова всего одна (layer.py:1097); IdleScheduler включается только из record_tool_call(), VOR в idle не подключён, 2 из 3 idle-задач — заглушки (_improve_summaries_batch/_check_index_health — пустые тела). Живой срез текущего проекта: 42/136 узлов ACTIVE без verified_at/TTL висят с 2026-08-11; узлы без якорей → INCONCLUSIVE → VOR не пишет ничего → «проверено» = «кто-то когда-то вызвал».
**Fix (план эксперимента, не внесён):** H1 idle-ticker VOR с budget; H2 event-driven на HEAD (ключ hash(node_id+commit_sha) уже есть); H3 TTL-гниение INCONCLUSIVE → STALE. Baseline замера: полный прогон 136 узлов = 431.6ms (fingerprint 371.6ms) — дешевле порога. Контр-риски: false_retraction не выше 0.083%, цена при нагрузке.
**Guard:** новые «проверки» проектной памяти обязаны иметь точку вызова вне ручного чтения (idle/event/ttl) — иначе это снова lazy-by-hand.
**verified_from_clean_state:** ⚠️ не прогонялся (изменения только .md, live-данные из реального сервера PID 10036)

---
## [2026-09-07] — Cypher-движок: анонимные узлы/рёбра ломали MATCH; ActionReceipt не писался из write-пути
**Status:** Fixed (оба блока закрыты, тесты зелёные)
**Root Cause:** (1) Cypher: `from_node_alias` дефолтил в `n1`, а генератор создавал `n{path_idx*2}` для анонимного узла → `no such column: n0.id`; переменная ребра `[e:]` не регистрировалась → `no such column: e`. (2) Receipts: `_contract_record` (ChangeIntent) вызывался только в rename-fallback и safe_delete; replace/insert/move/workspace_edit писали файл напрямую → ни ChangeIntent, ни ActionReceipt.
**Fix:** (1) cypher_sql.py: alias левого узла резолвится в `n{path_idx*2}`, `edge_vars` + `edge_prop_map` (type/source_id/target_id → колонки, остальное → json_extract), `count(e)` → COUNT(e.id). 10 регресс-тестов + 5 Red Team атак. (2) write_tools.py: новый `_contract_receipt()` (build_receipt + ActionReceiptStore) вызывается из `_contract_record`; сам `_contract_record` добавлен во все write-пути (replace/insert/rename-LSP/move включая refs). Receipt-запись warning-only, не валит write. +1 тест (JSONL создаётся, verdict VERIFIED).
**Guard:** write-операция без ChangeIntent+ActionReceipt = дефект; правило «каждый write пишет оба артефакта». collect() остаётся open (сочтён отдельной записью KNOWN_ISSUES).
**verified_from_clean_state:** ⚠️ не прогонялся (изменения в 2 файлах, pytest tests/ 1629 passed + ruff clean)

---
**Status:** Fixed (branch closed, artifact merged into main)
**Root Cause:** experiment/lab-2026 branch (spike exp-lab-2026-01: NL->LLM->Cypher->parser+schema->PropertyGraph) was orphaned - its artifact experiments/neuro_symbolic_spike.py and EXPERIMENTS_LOG entry never landed on main. Findings C1-C4 were already fixed on main via D1 (CypherExecutor schema layer).
**Fix:** Re-ran spike from clean main (VERDICT: HYPOTHESIS SUPPORTED, parse_ok=8, rejected_by_schema=2 - schema layer correctly rejects hallucinated :SERVICE label and cycle() empty-RETURN). Carried only the useful artifact (spike script + EXPERIMENTS_LOG entry 848fdf33), avoiding a blind merge that would have conflicted in 3 doc files. Deleted orphaned local branch experiment/lab-2026.
Expand Down
1 change: 0 additions & 1 deletion KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,3 @@
- **Fix:** пара добавлена в `_ALLOWED_CORE_CYCLES` (scripts/architecture_linter.py) с комментарием; `LANGUAGE_IMPORT_NODES` переведён на ленивую деривацию (кэш `_LANGUAGE_IMPORT_NODES_CACHE`, `__getattr__`), прямое обращение к карте внутри модуля заменено на `_get_language_import_nodes()`. Удалить из allowlist после выноса `IMPORT_NODE_MAP` в нейтральный модуль (не историю карт в parser) — тогда language_imports сможет импортировать parser односторонне.
- **Статус:** ✅ Fixed (allowed tech debt, deferred refactor; целевые 68 passed, architecture_linter 4/4 OK)

>>>>>>> 86ef986d (feat(indexing): derive import maps from parser and gate fallback behind language pack flag)
70 changes: 55 additions & 15 deletions src/core/search/cypher_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,22 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]:

# Фаза 1: определяем все переменные узлов и их алиасы в SQL
node_vars: Dict[str, str] = {} # переменная Cypher → SQL алиас
edge_vars: Dict[str, str] = {} # переменная ребра [e:] → SQL алиас
path_joins: List[str] = []
path_where: List[str] = [] # WHERE условия из label/type фильтров
path_where_params: List[Any] = [] # params для path_where (добавляются в конце)
params: List[Any] = []
select_cols: List[str] = []

for path_idx, path in enumerate(query.match.paths):
self._process_path_pattern(path, node_vars, path_joins, path_where, params, path_idx, path_where_params)
self._process_path_pattern(path, node_vars, edge_vars, path_joins, path_where, params, path_idx, path_where_params)

# Фаза 1.5: OPTIONAL MATCH — LEFT JOIN
opt_path_counter = len(query.match.paths)
for opt_clause in query.optional_match:
for opt_path in opt_clause.paths:
self._process_path_pattern(
opt_path, node_vars, path_joins, path_where, params,
opt_path, node_vars, edge_vars, path_joins, path_where, params,
opt_path_counter, path_where_params,
join_type="LEFT JOIN", left_labels_in_on=True,
)
Expand All @@ -67,14 +68,14 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]:
params.extend(path_where_params)

if query.where:
self._process_where(query.where.expr, node_vars, where_clauses, params)
self._process_where(query.where.expr, node_vars, edge_vars, where_clauses, params)

# Фаза 3: RETURN
agg_columns = []
group_by = []

for item in query.return_items:
sql_col = self._translate_return_expr(item.expression, node_vars)
sql_col = self._translate_return_expr(item.expression, node_vars, edge_vars)
if self._is_aggregate(item.expression):
agg_columns.append(sql_col)
else:
Expand All @@ -101,7 +102,10 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]:
select_distinct = "DISTINCT " if query.return_distinct else ""

# FROM — первый узел первого паттерна (target)
from_node_alias = node_vars.get(query.match.paths[0].left.variable or "n", "n1")
first_path = query.match.paths[0]
from_node_alias = node_vars.get(
first_path.left.variable or f"n{0 * 2}", "n1"
)

columns_sql = ", ".join(select_cols)
joins_sql = "\n".join(path_joins)
Expand All @@ -118,7 +122,7 @@ def translate(self, query: Query) -> Tuple[str, List[Any]]:
if query.order_by:
order_parts = []
for o in query.order_by:
col = self._translate_return_expr(o.expression, node_vars)
col = self._translate_return_expr(o.expression, node_vars, edge_vars)
order_parts.append(f"{col} {o.direction}")
order_sql = f"ORDER BY {', '.join(order_parts)}"

Expand Down Expand Up @@ -147,6 +151,7 @@ def _process_path_pattern(
self,
path: PathPattern,
node_vars: Dict[str, str],
edge_vars: Dict[str, str],
joins: List[str],
wheres: List[str],
params: List[Any],
Expand Down Expand Up @@ -193,7 +198,9 @@ def _process_path_pattern(
node_vars[right_var] = right_var

# Ребро
edge_alias = f"e{path_idx}"
edge_alias = path.rel.variable if path.rel.variable else f"e{path_idx}"
if path.rel.variable:
edge_vars[path.rel.variable] = edge_alias
edge_on = "" # дополнительное условие для ON

if path.rel.rel_types:
Expand Down Expand Up @@ -265,12 +272,13 @@ def _process_where(
self,
expr: ASTNode,
node_vars: Dict[str, str],
edge_vars: Dict[str, str],
clauses: List[str],
params: List[Any],
):
"""Рекурсивно обрабатывает WHERE."""
if isinstance(expr, Comparison):
sql_ref = self._property_ref_to_sql(expr.left, node_vars)
sql_ref = self._property_ref_to_sql(expr.left, node_vars, edge_vars)

if expr.op in ("IN",):
if isinstance(expr.right, list):
Expand Down Expand Up @@ -322,8 +330,8 @@ def _process_where(
elif isinstance(expr, _BinaryOp):
left_clauses: List[str] = []
right_clauses: List[str] = []
self._process_where(expr.left, node_vars, left_clauses, params)
self._process_where(expr.right, node_vars, right_clauses, params)
self._process_where(expr.left, node_vars, edge_vars, left_clauses, params)
self._process_where(expr.right, node_vars, edge_vars, right_clauses, params)

all_clauses = left_clauses + right_clauses
if expr.op == "OR":
Expand All @@ -333,7 +341,7 @@ def _process_where(

elif isinstance(expr, _UnaryOp):
inner: List[str] = []
self._process_where(expr.expr, node_vars, inner, params)
self._process_where(expr.expr, node_vars, edge_vars, inner, params)
if expr.op == "NOT":
clauses.append(f"NOT ({inner[0]})" if inner else "1=0")

Expand Down Expand Up @@ -366,11 +374,32 @@ def _process_where(
f"EXISTS (SELECT 1 FROM edges e WHERE e.source_id = {left_alias}.id {edge_filter})"
)

def _property_ref_to_sql(self, ref: str, node_vars: Dict[str, str]) -> str:
def _property_ref_to_sql(
self, ref: str, node_vars: Dict[str, str], edge_vars: Optional[Dict[str, str]] = None
) -> str:
"""Переводит n.name или n.label в SQL: n_alias.name или n_alias.label."""
parts = ref.split(".")
if len(parts) == 2:
var, prop = parts
if edge_vars and var in edge_vars:
alias = edge_vars[var]

# Validate property name - defense in depth against SQL injection
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", prop):
raise ValueError(f"Invalid property name: {prop}")

# Специальные имена свойств ребра
edge_prop_map = {
"type": "type",
"source_id": "source_id",
"target_id": "target_id",
"id": "id",
}
if prop in edge_prop_map:
return f"{alias}.{edge_prop_map[prop]}"
# properties JSON path (e.g. e.weight)
return f"json_extract({alias}.properties, '$.{prop}')"

alias = node_vars.get(var, var)

# Validate property name - defense in depth against SQL injection
Expand All @@ -397,7 +426,9 @@ def _property_ref_to_sql(self, ref: str, node_vars: Dict[str, str]) -> str:

return ref

def _translate_return_expr(self, expr: str, node_vars: Dict[str, str]) -> str:
def _translate_return_expr(
self, expr: str, node_vars: Dict[str, str], edge_vars: Optional[Dict[str, str]] = None
) -> str:
"""Переводит RETURN выражение в SQL."""
# count(*)
if expr == "count(*)":
Expand Down Expand Up @@ -425,7 +456,16 @@ def _translate_return_expr(self, expr: str, node_vars: Dict[str, str]) -> str:
f"Aggregate {func}({inner}) over node variable is not supported; "
f"use a property, e.g. {inner}.name"
)
sql_inner = self._property_ref_to_sql(inner, node_vars)
if edge_vars and inner in edge_vars:
# count(e) / count(e.type) над ребром-переменной
alias = edge_vars[inner]
if func == "COUNT":
return f"COUNT({alias}.id)"
raise ValueError(
f"Aggregate {func}({inner}) over edge variable is not supported; "
f"use a property, e.g. {inner}.type"
)
sql_inner = self._property_ref_to_sql(inner, node_vars, edge_vars)
return f"{func}({sql_inner})"

# C4: неизвестная функция в RETURN — явная ошибка вместо невалидного SQL
Expand All @@ -438,7 +478,7 @@ def _translate_return_expr(self, expr: str, node_vars: Dict[str, str]) -> str:
)

# Простое свойство
return self._property_ref_to_sql(expr, node_vars)
return self._property_ref_to_sql(expr, node_vars, edge_vars)

def _is_aggregate(self, expr: str) -> bool:
return bool(re.match(r"(count|sum|avg|min|max|collect)\(", expr, re.IGNORECASE))
Loading
Loading