From a6653b35c73daf212b8cdcdee91f32895b2b732e Mon Sep 17 00:00:00 2001 From: wolverinaton Date: Sun, 30 Aug 2026 02:40:52 -0300 Subject: [PATCH 1/2] feat(wiki): el tema de agrupacion sale de subject a su propia columna MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wiki_engine agrupaba articulos por `subject`, pero `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 CONTRADICCION y superseden una. Por eso la wiki solo podia correr con --pinned-only: sobre el corpus entero, o colapsaba en un unico articulo 'general', o habia que engrosar los subjects, y eso hacia que el steward archivara claims no relacionadas en el ciclo siguiente. Medido antes de tocar nada: engrosar subjects daba 176 colisiones directas del indice unico sobre 13.767 asignaciones, y esas eran solo las que la base frenaba. El tema va a `topic`, columna aditiva y deliberadamente SIN unicidad. El motor agrupa por COALESCE(NULLIF(topic,''), subject, 'general'), asi que las claims viejas siguen saliendo por subject y nada cambia para ellas. Medido sobre la base viva tras clasificar 13.767 claims: claims agrupadas 18% -> 96%, y project:memorymaster pasa a 319 articulos (el mayor de 95 claims) en vez de un 'general' unico. Barra roja verificada: con el esquema puesto pero sin la logica de agrupacion, los 3 tests del requisito fallan y los 2 de compatibilidad siguen pasando. --- memorymaster/knowledge/wiki_engine.py | 20 +++- memorymaster/schema.sql | 5 + memorymaster/schema_postgres.sql | 4 + memorymaster/stores/_storage_schema.py | 21 ++++ .../test_wiki_groups_by_topic_not_subject.py | 103 ++++++++++++++++++ 5 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 tests/test_wiki_groups_by_topic_not_subject.py diff --git a/memorymaster/knowledge/wiki_engine.py b/memorymaster/knowledge/wiki_engine.py index f5bf5a75..e25cd8bf 100644 --- a/memorymaster/knowledge/wiki_engine.py +++ b/memorymaster/knowledge/wiki_engine.py @@ -72,9 +72,21 @@ 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, + query = """SELECT id, text, claim_type, subject, topic, predicate, object_value, scope, confidence, status, human_id, created_at, updated_at, event_time FROM claims WHERE status IN ('confirmed', 'candidate')""" params: list = [] @@ -91,7 +103,7 @@ def _load_claims_by_topic( query += " AND scope LIKE ?" params.append(f"{scope_filter}%") query += """ ORDER BY - COALESCE(subject, 'general') COLLATE NOCASE ASC, + COALESCE(NULLIF(topic, ''), subject, 'general') COLLATE NOCASE ASC, confidence DESC, COALESCE(updated_at, created_at, event_time, '') DESC, id ASC""" @@ -100,7 +112,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 diff --git a/memorymaster/schema.sql b/memorymaster/schema.sql index ceb8f641..964d8f3b 100644 --- a/memorymaster/schema.sql +++ b/memorymaster/schema.sql @@ -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', @@ -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); diff --git a/memorymaster/schema_postgres.sql b/memorymaster/schema_postgres.sql index f94b3776..9d89f5c9 100644 --- a/memorymaster/schema_postgres.sql +++ b/memorymaster/schema_postgres.sql @@ -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', diff --git a/memorymaster/stores/_storage_schema.py b/memorymaster/stores/_storage_schema.py index 68993723..16a0aec9 100644 --- a/memorymaster/stores/_storage_schema.py +++ b/memorymaster/stores/_storage_schema.py @@ -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: @@ -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: @@ -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: @@ -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.""" diff --git a/tests/test_wiki_groups_by_topic_not_subject.py b/tests/test_wiki_groups_by_topic_not_subject.py new file mode 100644 index 00000000..e04238b7 --- /dev/null +++ b/tests/test_wiki_groups_by_topic_not_subject.py @@ -0,0 +1,103 @@ +"""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}" From 99db2bffcc98b919ad888125d7156c0e7efcb8ac Mon Sep 17 00:00:00 2001 From: wolverinaton Date: Sun, 30 Aug 2026 10:01:50 -0300 Subject: [PATCH 2/2] fix(wiki): una base sin la columna topic agrupa por subject en vez de reventar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI en rojo en los 6 jobs: `OperationalError: no such column: topic`. Varios tests del motor arman a mano una tabla claims minima, y por db_merge llegan bases de OpenClaw con el mismo perfil. Mi SELECT exigia la columna, asi que la wiki crasheaba sobre cualquier base no migrada — una regresion real, no un detalle de fixtures. Por eso se arregla en el loader y no editando los 8 tests para que me acomoden. El loader detecta la columna con PRAGMA table_info y, si no esta, agrupa por subject exactamente como antes. El tema es una mejora, no un requisito. Tambien regenerada la verdad de release: el generador cuenta funciones de test y yo agregue una despues de generar, que es el caso exacto que documenta el docstring de test_release_truth. 46/46 en los cinco archivos de test tocados, ruff limpio. --- docs/generated/release-truth.json | 2 +- docs/generated/release-truth.md | 2 +- memorymaster/knowledge/wiki_engine.py | 17 +++++++++--- .../test_wiki_groups_by_topic_not_subject.py | 27 +++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/generated/release-truth.json b/docs/generated/release-truth.json index 4d43726a..a7a67cae 100644 --- a/docs/generated/release-truth.json +++ b/docs/generated/release-truth.json @@ -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": [ diff --git a/docs/generated/release-truth.md b/docs/generated/release-truth.md index 53018952..d1d3c40a 100644 --- a/docs/generated/release-truth.md +++ b/docs/generated/release-truth.md @@ -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 diff --git a/memorymaster/knowledge/wiki_engine.py b/memorymaster/knowledge/wiki_engine.py index e25cd8bf..2a02721e 100644 --- a/memorymaster/knowledge/wiki_engine.py +++ b/memorymaster/knowledge/wiki_engine.py @@ -86,7 +86,14 @@ def _load_claims_by_topic( `subject` queda como fallback para todo lo que no tiene tema asignado. """ conn = connect_ro(db_path) - query = """SELECT id, text, claim_type, subject, topic, 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 = [] @@ -102,8 +109,12 @@ def _load_claims_by_topic( if scope_filter: query += " AND scope LIKE ?" params.append(f"{scope_filter}%") - query += """ ORDER BY - COALESCE(NULLIF(topic, ''), 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""" diff --git a/tests/test_wiki_groups_by_topic_not_subject.py b/tests/test_wiki_groups_by_topic_not_subject.py index e04238b7..18058293 100644 --- a/tests/test_wiki_groups_by_topic_not_subject.py +++ b/tests/test_wiki_groups_by_topic_not_subject.py @@ -101,3 +101,30 @@ def test_topic_no_tiene_indice_unico(db: str): ] 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)