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
2 changes: 1 addition & 1 deletion docs/generated/release-truth.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
"console_entrypoints": 8,
"mcp_tools": 51,
"ops_cli_commands": 5,
"pytest_test_functions": 4113
"pytest_test_functions": 4119
},
"feature_profile_matrix": {
"capture_hook": [
Expand Down
2 changes: 1 addition & 1 deletion docs/generated/release-truth.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Do not edit this file by hand. Run `python scripts/generate_release_truth.py`.
- Main CLI commands: **119**
- Operations CLI commands: **5**
- Console entrypoints: **8**
- Pytest source test functions: **4113**
- Pytest source test functions: **4119**

## MCP tools

Expand Down
33 changes: 28 additions & 5 deletions memorymaster/knowledge/wiki_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,28 @@ def _load_claims_by_topic(
*,
pinned_only: bool = False,
) -> dict[str, list[dict]]:
"""Load claims grouped by subject."""
"""Agrupa claims por tema, prefiriendo `topic` sobre `subject`.

`subject` NO es una etiqueta de tema: es el sujeto de una tripleta, y tres
mecanismos lo tratan como identidad — el indice unico
(tenant, subject, predicate, scope) sobre confirmadas publicas,
`auto_resolver` y `conflict_resolver`, que leen dos claims con el mismo
(subject, predicate) y distinto object_value como una CONTRADICCION y
superseden una. Engrosar subjects para que agrupen mejor haria que el
steward archivara claims no relacionadas en el siguiente ciclo.

Por eso el tema vive en su propia columna, sin restriccion de unicidad, y
`subject` queda como fallback para todo lo que no tiene tema asignado.
"""
conn = connect_ro(db_path)
query = """SELECT id, text, claim_type, subject, predicate, object_value,
# Una base sin migrar (o armada a mano, como en varios tests y en lo que
# llega por db_merge de OpenClaw) no tiene `topic`. Se degrada a agrupar por
# subject en vez de reventar: el tema es una mejora, no un requisito.
has_topic = any(
r[1] == "topic" for r in conn.execute("PRAGMA table_info(claims)")
)
topic_col = "topic" if has_topic else "NULL AS topic"
query = f"""SELECT id, text, claim_type, subject, {topic_col}, predicate, object_value,
scope, confidence, status, human_id, created_at, updated_at, event_time
FROM claims WHERE status IN ('confirmed', 'candidate')"""
params: list = []
Expand All @@ -90,8 +109,12 @@ def _load_claims_by_topic(
if scope_filter:
query += " AND scope LIKE ?"
params.append(f"{scope_filter}%")
query += """ ORDER BY
COALESCE(subject, 'general') COLLATE NOCASE ASC,
order_key = (
"COALESCE(NULLIF(topic, ''), subject, 'general')" if has_topic
else "COALESCE(subject, 'general')"
)
query += f""" ORDER BY
{order_key} COLLATE NOCASE ASC,
confidence DESC,
COALESCE(updated_at, created_at, event_time, '') DESC,
id ASC"""
Expand All @@ -100,7 +123,7 @@ def _load_claims_by_topic(

by_subject: dict[str, list[dict]] = {}
for r in rows:
subj = r["subject"] or "general"
subj = (r["topic"] or "").strip() or r["subject"] or "general"
by_subject.setdefault(subj, []).append(dict(r))
return by_subject

Expand Down
5 changes: 5 additions & 0 deletions memorymaster/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ CREATE TABLE IF NOT EXISTS claims (
normalized_text TEXT,
claim_type TEXT,
subject TEXT,
-- tema de agrupacion para la wiki. Deliberadamente SIN restriccion de
-- unicidad: subject es el sujeto de una tripleta y el resolver de
-- conflictos lo trata como identidad, asi que el tema no puede vivir ahi.
topic TEXT,
predicate TEXT,
object_value TEXT,
scope TEXT NOT NULL DEFAULT 'project',
Expand Down Expand Up @@ -198,6 +202,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_nonpublic_principal_idempotency_key
WHERE visibility <> 'public' AND source_agent IS NOT NULL
AND idempotency_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_claims_tuple ON claims(subject, predicate, scope);
CREATE INDEX IF NOT EXISTS idx_claims_topic ON claims(topic);
CREATE INDEX IF NOT EXISTS idx_claims_replaced_by ON claims(replaced_by_claim_id);
CREATE INDEX IF NOT EXISTS idx_citations_claim_id ON citations(claim_id);
CREATE INDEX IF NOT EXISTS idx_events_claim_id ON events(claim_id);
Expand Down
4 changes: 4 additions & 0 deletions memorymaster/schema_postgres.sql
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ CREATE TABLE IF NOT EXISTS claims (
normalized_text TEXT,
claim_type TEXT,
subject TEXT,
-- tema de agrupacion para la wiki. Deliberadamente SIN restriccion de
-- unicidad: subject es el sujeto de una tripleta y el resolver de
-- conflictos lo trata como identidad, asi que el tema no puede vivir ahi.
topic TEXT,
predicate TEXT,
object_value TEXT,
scope TEXT NOT NULL DEFAULT 'project',
Expand Down
21 changes: 21 additions & 0 deletions memorymaster/stores/_storage_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ def _ensure_claim_idempotency_schema(conn: sqlite3.Connection) -> None:
_SchemaMixin._ensure_tenant_id_schema(conn)
_SchemaMixin._ensure_scope_schema(conn)
_SchemaMixin._ensure_agent_columns(conn)
_SchemaMixin._ensure_topic_column(conn)
try:
conn.execute("ALTER TABLE claims ADD COLUMN idempotency_key TEXT")
except sqlite3.OperationalError as exc:
Expand Down Expand Up @@ -326,6 +327,7 @@ def _ensure_confirmed_tuple_uniqueness_schema(conn: sqlite3.Connection) -> None:
_SchemaMixin._ensure_tenant_id_schema(conn)
_SchemaMixin._ensure_scope_schema(conn)
_SchemaMixin._ensure_agent_columns(conn)
_SchemaMixin._ensure_topic_column(conn)
for trigger in SQLITE_CONFIRMED_TUPLE_GUARD_TRIGGERS:
conn.execute(f"DROP TRIGGER IF EXISTS {trigger}")
try:
Expand Down Expand Up @@ -580,6 +582,7 @@ def _ensure_human_id_schema(conn: sqlite3.Connection) -> None:
_SchemaMixin._ensure_tenant_id_schema(conn)
_SchemaMixin._ensure_scope_schema(conn)
_SchemaMixin._ensure_agent_columns(conn)
_SchemaMixin._ensure_topic_column(conn)
try:
conn.execute("ALTER TABLE claims ADD COLUMN human_id TEXT")
except sqlite3.OperationalError as exc:
Expand Down Expand Up @@ -815,6 +818,24 @@ def _ensure_temporal_columns(conn) -> None:
)


@staticmethod
def _ensure_topic_column(conn: sqlite3.Connection) -> None:
"""Agrega `topic`, la clave de agrupacion de la wiki.

Vive aparte de `subject` a proposito: `subject` es el sujeto de una
tripleta y `conflict_resolver` lee dos claims con el mismo
(subject, predicate) y distinto object_value como contradiccion. Un tema
agrupa decenas de claims, asi que ponerlo en `subject` haria que el
steward las archivara entre si. `topic` no lleva unicidad por eso mismo.
"""
try:
conn.execute("ALTER TABLE claims ADD COLUMN topic TEXT")
except sqlite3.OperationalError as exc:
if "duplicate column name" not in str(exc).lower():
raise
conn.execute("CREATE INDEX IF NOT EXISTS idx_claims_topic ON claims(topic)")


@staticmethod
def _ensure_agent_columns(conn: sqlite3.Connection) -> None:
"""Add source_agent and visibility columns if missing."""
Expand Down
130 changes: 130 additions & 0 deletions tests/test_wiki_groups_by_topic_not_subject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""El tema de la wiki vive en `topic`, no en `subject`, y por una razon dura.

`subject` es el sujeto de una tripleta y TRES mecanismos lo tratan como
identidad: el indice unico (tenant, subject, predicate, scope) sobre confirmadas
publicas, `auto_resolver` y `conflict_resolver` — estos dos leen dos claims con
el mismo (subject, predicate) y distinto object_value como una CONTRADICCION y
superseden una. Un tema de wiki agrupa decenas de claims, asi que escribir el
tema en `subject` hacia que el steward archivara claims no relacionadas entre si
en el siguiente ciclo. Medido antes de escribir nada: 176 colisiones directas del
indice unico sobre 13.767 asignaciones, y esas eran solo las que la base frenaba.

Estos tests anclan el REQUISITO ("un tema agrupa muchas claims sin que el sistema
las lea como contradictorias"), no la implementacion: siguen valiendo si cambia
como se hace la consulta, y fallan si alguien vuelve a poner el tema en `subject`
o le agrega unicidad a `topic`.
"""
from __future__ import annotations

import sqlite3
from pathlib import Path

import pytest

from memorymaster.core.service import MemoryService
from memorymaster.knowledge.wiki_engine import _load_claims_by_topic

BASE = (
"INSERT INTO claims (id, text, claim_type, subject, topic, predicate,"
" object_value, scope, status, pinned, confidence, created_at, updated_at,"
" volatility, tier, version, visibility)"
" VALUES (?,?,'fact',?,?,?,?,'project:mm','confirmed',1,0.9,"
"'2026-08-30T00:00:00+00:00','2026-08-30T00:00:00+00:00','low','core',1,'public')"
)


@pytest.fixture()
def db(tmp_path: Path) -> str:
path = str(tmp_path / "wiki.db")
MemoryService(path, workspace_root=str(tmp_path)).init_db()
return path


def _insert(db_path: str, rows: list[tuple]) -> None:
conn = sqlite3.connect(db_path)
conn.executemany(BASE, rows)
conn.commit()
conn.close()


def test_el_tema_agrupa_claims_con_sujetos_distintos(db: str):
"""Lo que la wiki necesita: un articulo por tema, no uno por sujeto."""
_insert(db, [
(1, "el sentinel corre programado", "MM-freshness-sentinel", "operacion", "corre", "si"),
(2, "el steward corre cada 6h", "MM-steward", "operacion", "corre", "si"),
(3, "el digest sale los lunes", "MM-digest", "operacion", "sale", "lunes"),
])
temas = _load_claims_by_topic(db)
assert set(temas) == {"operacion"}, f"no agrupo por tema: {sorted(temas)}"
assert {c["id"] for c in temas["operacion"]} == {1, 2, 3}


def test_sin_tema_cae_en_subject(db: str):
"""Backward compat: las claims viejas no tienen `topic` y deben seguir saliendo."""
_insert(db, [
(1, "una claim vieja sin tema asignado", "vault-curado", None, "es", "vieja"),
(2, "otra claim vieja con tema vacio", "ruido", "", "es", "vieja"),
])
temas = _load_claims_by_topic(db)
assert set(temas) == {"vault-curado", "ruido"}, sorted(temas)


def test_el_tema_gana_cuando_estan_los_dos(db: str):
_insert(db, [(1, "tiene ambos", "sujeto-viejo", "tema-nuevo", "es", "x")])
assert set(_load_claims_by_topic(db)) == {"tema-nuevo"}


def test_muchas_claims_comparten_tema_y_predicado_sin_violar_el_indice(db: str):
"""La razon de ser de la columna.

Con el tema en `subject`, estas tres claims —mismo sujeto, mismo predicado,
distinto object_value— violan idx_claims_public_confirmed_tuple_unique y,
peor, son exactamente el patron que `conflict_resolver` supersede. En
`topic` conviven, que es lo que un articulo de wiki necesita.
"""
_insert(db, [
(1, "primera", "sujeto-a", "gotchas de windows", "requiere", "comillas"),
(2, "segunda", "sujeto-b", "gotchas de windows", "requiere", "rutas absolutas"),
(3, "tercera", "sujeto-c", "gotchas de windows", "requiere", "pythonw"),
])
temas = _load_claims_by_topic(db)
assert len(temas["gotchas de windows"]) == 3


def test_topic_no_tiene_indice_unico(db: str):
"""Si alguien le agrega unicidad a `topic`, vuelve el problema que origino todo."""
conn = sqlite3.connect(db)
unicos = [
sql for (sql,) in conn.execute(
"SELECT sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL"
) if "topic" in sql and "UNIQUE" in sql.upper()
]
conn.close()
assert not unicos, f"topic quedo con unicidad, que es justo lo que no puede tener: {unicos}"


def test_una_base_sin_la_columna_sigue_funcionando(tmp_path: Path):
"""Bases viejas, armadas a mano o llegadas por db_merge no tienen `topic`.

El tema es una mejora, no un requisito: sin la columna el motor agrupa por
subject como siempre. Sin esto, la wiki reventaba con
`OperationalError: no such column: topic` sobre cualquier base no migrada.
"""
path = str(tmp_path / "vieja.db")
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE claims (id INTEGER PRIMARY KEY, text TEXT, claim_type TEXT,"
" subject TEXT, predicate TEXT, object_value TEXT, scope TEXT, confidence REAL,"
" status TEXT, human_id TEXT, created_at TEXT, updated_at TEXT, event_time TEXT,"
" pinned INTEGER DEFAULT 0)"
)
conn.execute(
"INSERT INTO claims (id, text, subject, scope, status, confidence)"
" VALUES (1, 'claim de una base sin migrar', 'tema-viejo', 'project:mm',"
" 'confirmed', 0.9)"
)
conn.commit()
conn.close()

temas = _load_claims_by_topic(path)
assert set(temas) == {"tema-viejo"}, sorted(temas)
Loading