diff --git a/src/neurostack/graph.py b/src/neurostack/graph.py index 5873436..26d3a95 100644 --- a/src/neurostack/graph.py +++ b/src/neurostack/graph.py @@ -260,5 +260,16 @@ def get_neighborhood( # Sort by PageRank descending neighbors.sort(key=lambda x: x.pagerank, reverse=True) - _record_note_usage(conn, [center.path] + [n.path for n in neighbors]) + # Returning a neighborhood is surfacing, not use (issues #95/#103/#109): + # log every returned path as 'primed' and, with feedback enabled, search-log + # the surfacing so a later deliberate read attributes back as 'used'. + surfaced = [center.path] + [n.path for n in neighbors] + _record_note_usage(conn, surfaced, tier="primed", source="graph") + from .config import get_config + + cfg = get_config() + if cfg.feedback_enabled: + from .feedback import log_search + + log_search(conn, note_path, surfaced, cfg.feedback_log_retention) return GraphResult(center=center, neighbors=neighbors) diff --git a/src/neurostack/related.py b/src/neurostack/related.py index f69d5ab..7803bea 100644 --- a/src/neurostack/related.py +++ b/src/neurostack/related.py @@ -121,5 +121,17 @@ def find_related( "summary": summary, }) - _record_note_usage(conn, [r["path"] for r in results]) + # Returning related notes is surfacing, not use (issues #95/#103/#109): + # log the returned paths as 'primed' and, with feedback enabled, search-log + # the surfacing so a later deliberate read attributes back as 'used'. + surfaced = [r["path"] for r in results] + _record_note_usage(conn, surfaced, tier="primed", source="related") + if surfaced: + from .config import get_config + + cfg = get_config() + if cfg.feedback_enabled: + from .feedback import log_search + + log_search(conn, note_path, surfaced, cfg.feedback_log_retention) return results diff --git a/src/neurostack/schema.py b/src/neurostack/schema.py index d8f9c59..46f5b1d 100644 --- a/src/neurostack/schema.py +++ b/src/neurostack/schema.py @@ -198,9 +198,11 @@ def __getattr__(name: str): -- Usage tracking for hotness scoring. tier is signal STRENGTH: deliberate use -- ('used' — vault_record_usage, inferred reads) vs auto-RAG injection -- ('primed' — vault_context / search returns; issue #95) — synaptic tag vs --- capture. source is PROVENANCE, i.e. which path wrote the row (issue #103): --- 'explicit' (record_usage), 'inferred' (read-after-surface), 'search' --- (search returns), 'context' (vault_context returns). +-- capture. source is PROVENANCE, i.e. which path wrote the row (issues +-- #103/#109): 'explicit' (record_usage), 'inferred' (read-after-surface), +-- 'search' (search returns), 'context' (vault_context returns), 'graph' +-- (vault_graph returns), 'related' (vault_related returns), 'summary' +-- (vault_summary returns). CREATE TABLE IF NOT EXISTS note_usage ( usage_id INTEGER PRIMARY KEY AUTOINCREMENT, note_path TEXT NOT NULL, diff --git a/src/neurostack/search.py b/src/neurostack/search.py index 073505c..2a1da50 100644 --- a/src/neurostack/search.py +++ b/src/neurostack/search.py @@ -101,9 +101,11 @@ def _record_note_usage( carry a small, capped, decaying weight in hotness — a synaptic tag, not a consolidation. - ``source`` is the PROVENANCE, i.e. which path wrote the row (issue #103): - ``'explicit'`` (record_usage), ``'inferred'`` (read-after-surface), - ``'search'`` (search returns), ``'context'`` (vault_context returns). + ``source`` is the PROVENANCE, i.e. which path wrote the row (issues + #103/#109): ``'explicit'`` (record_usage), ``'inferred'`` + (read-after-surface), ``'search'`` (search returns), ``'context'`` + (vault_context returns), ``'graph'`` (vault_graph returns), ``'related'`` + (vault_related returns), ``'summary'`` (vault_summary returns). """ if not note_paths: return diff --git a/src/neurostack/tools/search_tools.py b/src/neurostack/tools/search_tools.py index 4dc759e..9e3fbc4 100644 --- a/src/neurostack/tools/search_tools.py +++ b/src/neurostack/tools/search_tools.py @@ -288,7 +288,18 @@ def vault_summary(path_or_query: str) -> dict: if not row: return {"error": "Note not found"} - _record_note_usage(conn, [row["path"]]) + # Returning a ~150-token summary is surfacing, not use (issues #95/#103/#109): + # 'primed' here; a follow-up read of the note itself infers the strong + # signal via capture_read. With feedback enabled, search-log the surfacing + # so that read attributes back. + _record_note_usage(conn, [row["path"]], tier="primed", source="summary") + from ..config import get_config + + cfg = get_config() + if cfg.feedback_enabled: + from ..feedback import log_search + + log_search(conn, path_or_query, [row["path"]], cfg.feedback_log_retention) return { "path": row["path"], diff --git a/tests/test_surface_provenance.py b/tests/test_surface_provenance.py new file mode 100644 index 0000000..c911904 --- /dev/null +++ b/tests/test_surface_provenance.py @@ -0,0 +1,210 @@ +"""Tests for surface-provenance downgrades (issue #109). + +vault_graph, vault_related, and vault_summary return notes the model may never +act on — a return is a 'primed' surface, not a use. These call sites used to +record tier='used' source='explicit' (the _record_note_usage defaults), which +polluted the strong signal #103 made observable. Acceptance: each tool's +returns leave only 'primed' rows with a distinct provenance source +('graph'/'related'/'summary'), never a 'used' row; with feedback enabled, the +surfacing is search-logged so a later read-after-surface infers the strong +'used'/'inferred' signal. + +Style mirrors test_inferred_usage.py: real in-memory sqlite, monkeypatched +get_db, never MagicMock. +""" + +import dataclasses +import json +import struct + +DIM = 768 + + +def _emb_blob(axis: int = 0) -> bytes: + v = [0.0] * DIM + v[axis] = 1.0 + return struct.pack(f"{DIM}f", *v) + + +def _add_note(conn, path, title="N", with_chunk=True, axis=0): + conn.execute( + "INSERT INTO notes (path, title, content_hash, updated_at) VALUES (?, ?, ?, ?)", + (path, title, f"h_{path}", "2026-01-01"), + ) + if with_chunk: + conn.execute( + "INSERT INTO chunks (note_path, heading_path, content, content_hash, " + "position, embedding) VALUES (?, ?, ?, ?, ?, ?)", + (path, "## H", "body text", f"hc_{path}", 0, _emb_blob(axis)), + ) + conn.commit() + + +def _enable_feedback(monkeypatch): + import neurostack.config as config_mod + + cfg = dataclasses.replace(config_mod.get_config(), feedback_enabled=True) + monkeypatch.setattr(config_mod, "get_config", lambda: cfg) + return cfg + + +def _usage_rows(conn): + return conn.execute( + "SELECT note_path, tier, source FROM note_usage ORDER BY usage_id" + ).fetchall() + + +def _search_log(conn): + return conn.execute( + "SELECT query, shown_paths FROM search_log ORDER BY search_id" + ).fetchall() + + +def _patch_schema_db(monkeypatch, conn): + import neurostack.schema as schema_mod + + monkeypatch.setattr(schema_mod, "get_db", lambda path: conn) + + +class TestGraphSurfacing: + def _seed(self, conn): + _add_note(conn, "a.md", with_chunk=False) + _add_note(conn, "b.md", with_chunk=False) + conn.execute( + "INSERT INTO graph_edges (source_path, target_path) VALUES ('a.md', 'b.md')" + ) + conn.commit() + + def test_neighborhood_returns_are_primed_graph(self, in_memory_db): + from neurostack.graph import get_neighborhood + + self._seed(in_memory_db) + result = get_neighborhood("a.md", conn=in_memory_db) + + assert result is not None + rows = _usage_rows(in_memory_db) + assert {r["note_path"] for r in rows} == {"a.md", "b.md"} + assert all((r["tier"], r["source"]) == ("primed", "graph") for r in rows) + + def test_feedback_enabled_logs_the_surfacing(self, in_memory_db, monkeypatch): + from neurostack.graph import get_neighborhood + + self._seed(in_memory_db) + _enable_feedback(monkeypatch) + get_neighborhood("a.md", conn=in_memory_db) + + logged = _search_log(in_memory_db) + assert len(logged) == 1 + assert logged[0]["query"] == "a.md" + assert set(json.loads(logged[0]["shown_paths"])) == {"a.md", "b.md"} + + def test_feedback_disabled_logs_nothing(self, in_memory_db): + from neurostack.graph import get_neighborhood + + self._seed(in_memory_db) + get_neighborhood("a.md", conn=in_memory_db) + + assert _search_log(in_memory_db) == [] + + +class TestRelatedSurfacing: + def test_related_returns_are_primed_related(self, in_memory_db, monkeypatch): + from neurostack.related import find_related + + _add_note(in_memory_db, "src.md", axis=0) + _add_note(in_memory_db, "kin.md", axis=0) + _patch_schema_db(monkeypatch, in_memory_db) + + results = find_related("src.md", top_k=5) + + assert [r["path"] for r in results] == ["kin.md"] + rows = _usage_rows(in_memory_db) + assert rows + assert all((r["tier"], r["source"]) == ("primed", "related") for r in rows) + assert {r["note_path"] for r in rows} == {"kin.md"} + + def test_feedback_enabled_logs_the_surfacing(self, in_memory_db, monkeypatch): + from neurostack.related import find_related + + _add_note(in_memory_db, "src.md", axis=0) + _add_note(in_memory_db, "kin.md", axis=0) + _patch_schema_db(monkeypatch, in_memory_db) + _enable_feedback(monkeypatch) + + find_related("src.md", top_k=5) + + logged = _search_log(in_memory_db) + assert len(logged) == 1 + assert logged[0]["query"] == "src.md" + assert json.loads(logged[0]["shown_paths"]) == ["kin.md"] + + def test_no_results_records_nothing(self, in_memory_db, monkeypatch): + from neurostack.related import find_related + + _add_note(in_memory_db, "lonely.md", axis=0) + _patch_schema_db(monkeypatch, in_memory_db) + _enable_feedback(monkeypatch) + + assert find_related("lonely.md", top_k=5) == [] + assert _usage_rows(in_memory_db) == [] + assert _search_log(in_memory_db) == [] + + +class TestSummarySurfacing: + def test_summary_return_is_primed_summary(self, in_memory_db, monkeypatch): + from neurostack.tools.search_tools import vault_summary + + _add_note(in_memory_db, "doc.md", with_chunk=False) + in_memory_db.execute( + "INSERT INTO summaries (note_path, summary_text, content_hash) " + "VALUES ('doc.md', 'A summary.', 'h')" + ) + in_memory_db.commit() + _patch_schema_db(monkeypatch, in_memory_db) + + out = vault_summary("doc.md") + + assert out["path"] == "doc.md" + rows = _usage_rows(in_memory_db) + assert len(rows) == 1 + assert (rows[0]["tier"], rows[0]["source"]) == ("primed", "summary") + + def test_feedback_enabled_logs_the_surfacing(self, in_memory_db, monkeypatch): + from neurostack.tools.search_tools import vault_summary + + _add_note(in_memory_db, "doc.md", with_chunk=False) + in_memory_db.commit() + _patch_schema_db(monkeypatch, in_memory_db) + _enable_feedback(monkeypatch) + + vault_summary("doc.md") + + logged = _search_log(in_memory_db) + assert len(logged) == 1 + assert logged[0]["query"] == "doc.md" + assert json.loads(logged[0]["shown_paths"]) == ["doc.md"] + + +class TestNoStrongRowsFromSurfacing: + def test_no_used_rows_anywhere(self, in_memory_db, monkeypatch): + """The point of #109: none of the three surfaces may write tier='used'.""" + from neurostack.graph import get_neighborhood + from neurostack.related import find_related + from neurostack.tools.search_tools import vault_summary + + _add_note(in_memory_db, "a.md", axis=0) + _add_note(in_memory_db, "b.md", axis=0) + in_memory_db.execute( + "INSERT INTO graph_edges (source_path, target_path) VALUES ('a.md', 'b.md')" + ) + in_memory_db.commit() + _patch_schema_db(monkeypatch, in_memory_db) + + get_neighborhood("a.md", conn=in_memory_db) + find_related("a.md", top_k=5) + vault_summary("a.md") + + used = in_memory_db.execute( + "SELECT COUNT(*) FROM note_usage WHERE tier = 'used'" + ).fetchone()[0] + assert used == 0