secondbrain-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Baseline save/load + diff for the graph-eval report (wave G4-d; spec §17d Q3).
|
|
2
|
+
|
|
3
|
+
The graph analogue of :mod:`brain.eval.baseline` for
|
|
4
|
+
:class:`brain.eval.graph_runner.GraphEvalReport`. A SEPARATE baseline path from
|
|
5
|
+
the hybrid ``EvalReport`` baseline because the two report shapes differ (graph
|
|
6
|
+
carries local/fuse ranked-doc metrics + themes set P/R/F1; spec §17d Q3).
|
|
7
|
+
|
|
8
|
+
This baseline is a **canary** — it round-trips in tests and is intentionally NOT
|
|
9
|
+
committed as a ``ci.json``, and there is **no** ``--fail-below`` gate (spec §17d
|
|
10
|
+
Q3): the blocking thresholds live in the synthetic-graph integration test, not a
|
|
11
|
+
committed-baseline CI gate. ``diff_graph_reports`` is for local/manual
|
|
12
|
+
regression inspection.
|
|
13
|
+
|
|
14
|
+
Conventions mirror :mod:`brain.eval.baseline`: atomic write, sorted keys, floats
|
|
15
|
+
rounded to 4 decimals for byte-stable diffs. The :func:`brain.eval.baseline._round_floats`
|
|
16
|
+
helper is reused (one rounding implementation for both baseline shapes).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from ..vault._atomic import atomic_write_text
|
|
28
|
+
from .baseline import _round_floats
|
|
29
|
+
from .errors import EvalBaselineError
|
|
30
|
+
from .graph_runner import GraphDocEvalResult, GraphEvalReport, GraphThemesEvalResult
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_graph_baseline(report: GraphEvalReport, *, path: Path) -> None:
|
|
34
|
+
"""Write *report* to *path* as JSON, atomically.
|
|
35
|
+
|
|
36
|
+
Keys are sorted and floats rounded to 4 decimals so repeated runs over the
|
|
37
|
+
same synthetic corpus produce byte-stable diffs. The parent directory is
|
|
38
|
+
created if missing. The caller owns *path* (no embedded baseline-name
|
|
39
|
+
validation — there is no CLI surface for graph baselines; spec §17d Q3).
|
|
40
|
+
"""
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
raw: dict[str, Any] = dataclasses.asdict(report)
|
|
43
|
+
raw = _round_floats(raw)
|
|
44
|
+
raw["generated_at"] = report.generated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
45
|
+
text = json.dumps(raw, sort_keys=True, default=str, indent=2) + "\n"
|
|
46
|
+
atomic_write_text(path, text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_graph_baseline(path: Path) -> GraphEvalReport:
|
|
50
|
+
"""Load a :class:`~brain.eval.graph_runner.GraphEvalReport` from *path*.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
EvalBaselineError: When the file is missing, contains invalid JSON, or
|
|
54
|
+
has an unexpected structure.
|
|
55
|
+
"""
|
|
56
|
+
if not path.exists():
|
|
57
|
+
raise EvalBaselineError(f"graph baseline file not found: {path}")
|
|
58
|
+
try:
|
|
59
|
+
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
except json.JSONDecodeError as exc:
|
|
61
|
+
raise EvalBaselineError(
|
|
62
|
+
f"graph baseline file contains invalid JSON: {path}: {exc}"
|
|
63
|
+
) from exc
|
|
64
|
+
try:
|
|
65
|
+
doc_results = [
|
|
66
|
+
GraphDocEvalResult(
|
|
67
|
+
mode=r["mode"],
|
|
68
|
+
query=r["query"],
|
|
69
|
+
expected_doc_ids=list(r["expected_doc_ids"]),
|
|
70
|
+
actual_doc_ids=list(r["actual_doc_ids"]),
|
|
71
|
+
ndcg_at_k=float(r["ndcg_at_k"]),
|
|
72
|
+
mrr=float(r["mrr"]),
|
|
73
|
+
recall_at_k=float(r["recall_at_k"]),
|
|
74
|
+
ndcg_k=int(r["ndcg_k"]),
|
|
75
|
+
recall_k=int(r["recall_k"]),
|
|
76
|
+
)
|
|
77
|
+
for r in data["doc_results"]
|
|
78
|
+
]
|
|
79
|
+
themes_results = [
|
|
80
|
+
GraphThemesEvalResult(
|
|
81
|
+
person=r["person"],
|
|
82
|
+
expected_theme_keysets=[list(ks) for ks in r["expected_theme_keysets"]],
|
|
83
|
+
actual_theme_keysets=[list(ks) for ks in r["actual_theme_keysets"]],
|
|
84
|
+
precision=float(r["precision"]),
|
|
85
|
+
recall=float(r["recall"]),
|
|
86
|
+
f1=float(r["f1"]),
|
|
87
|
+
matched=int(r["matched"]),
|
|
88
|
+
n_expected=int(r["n_expected"]),
|
|
89
|
+
n_actual=int(r["n_actual"]),
|
|
90
|
+
)
|
|
91
|
+
for r in data["themes_results"]
|
|
92
|
+
]
|
|
93
|
+
generated_at = datetime.fromisoformat(data["generated_at"])
|
|
94
|
+
if generated_at.tzinfo is None:
|
|
95
|
+
generated_at = generated_at.replace(tzinfo=UTC)
|
|
96
|
+
return GraphEvalReport(
|
|
97
|
+
doc_results=doc_results,
|
|
98
|
+
themes_results=themes_results,
|
|
99
|
+
mean_local_ndcg_at_k=float(data["mean_local_ndcg_at_k"]),
|
|
100
|
+
mean_local_mrr=float(data["mean_local_mrr"]),
|
|
101
|
+
mean_local_recall_at_k=float(data["mean_local_recall_at_k"]),
|
|
102
|
+
mean_fuse_ndcg_at_k=float(data["mean_fuse_ndcg_at_k"]),
|
|
103
|
+
mean_fuse_mrr=float(data["mean_fuse_mrr"]),
|
|
104
|
+
mean_fuse_recall_at_k=float(data["mean_fuse_recall_at_k"]),
|
|
105
|
+
mean_themes_precision=float(data["mean_themes_precision"]),
|
|
106
|
+
mean_themes_recall=float(data["mean_themes_recall"]),
|
|
107
|
+
mean_themes_f1=float(data["mean_themes_f1"]),
|
|
108
|
+
config_signature=dict(data["config_signature"]),
|
|
109
|
+
generated_at=generated_at,
|
|
110
|
+
)
|
|
111
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
112
|
+
raise EvalBaselineError(
|
|
113
|
+
f"graph baseline file has unexpected structure: {path}: {exc}"
|
|
114
|
+
) from exc
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Diff helpers
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class GraphDocDiff:
|
|
124
|
+
"""Per-case ranked-doc metric delta (current - baseline; negative = worse)."""
|
|
125
|
+
|
|
126
|
+
mode: str
|
|
127
|
+
query: str
|
|
128
|
+
ndcg_at_k_delta: float
|
|
129
|
+
mrr_delta: float
|
|
130
|
+
recall_at_k_delta: float
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class GraphThemesDiff:
|
|
135
|
+
"""Per-person themes metric delta (current - baseline; negative = worse)."""
|
|
136
|
+
|
|
137
|
+
person: str
|
|
138
|
+
precision_delta: float
|
|
139
|
+
recall_delta: float
|
|
140
|
+
f1_delta: float
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class GraphBaselineDiff:
|
|
145
|
+
"""Aggregate diff between two :class:`GraphEvalReport`s."""
|
|
146
|
+
|
|
147
|
+
per_doc: list[GraphDocDiff]
|
|
148
|
+
per_themes: list[GraphThemesDiff]
|
|
149
|
+
mean_local_ndcg_at_k_delta: float
|
|
150
|
+
mean_local_mrr_delta: float
|
|
151
|
+
mean_local_recall_at_k_delta: float
|
|
152
|
+
mean_fuse_ndcg_at_k_delta: float
|
|
153
|
+
mean_fuse_mrr_delta: float
|
|
154
|
+
mean_fuse_recall_at_k_delta: float
|
|
155
|
+
mean_themes_precision_delta: float
|
|
156
|
+
mean_themes_recall_delta: float
|
|
157
|
+
mean_themes_f1_delta: float
|
|
158
|
+
config_signature_changed: bool
|
|
159
|
+
baseline_signature: dict[str, Any]
|
|
160
|
+
current_signature: dict[str, Any]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def diff_graph_reports(
|
|
164
|
+
baseline: GraphEvalReport, current: GraphEvalReport
|
|
165
|
+
) -> GraphBaselineDiff:
|
|
166
|
+
"""Compute the delta between *baseline* and *current*.
|
|
167
|
+
|
|
168
|
+
Doc results are matched by ``(mode, query)``; themes results by ``person``.
|
|
169
|
+
A case present in one report but not the other contributes a 0.0 baseline
|
|
170
|
+
side (mirroring :func:`brain.eval.baseline.diff_reports`).
|
|
171
|
+
"""
|
|
172
|
+
base_doc = {(r.mode, r.query): r for r in baseline.doc_results}
|
|
173
|
+
curr_doc = {(r.mode, r.query): r for r in current.doc_results}
|
|
174
|
+
per_doc: list[GraphDocDiff] = []
|
|
175
|
+
for key in dict.fromkeys(list(base_doc) + list(curr_doc)):
|
|
176
|
+
b = base_doc.get(key)
|
|
177
|
+
c = curr_doc.get(key)
|
|
178
|
+
per_doc.append(
|
|
179
|
+
GraphDocDiff(
|
|
180
|
+
mode=key[0],
|
|
181
|
+
query=key[1],
|
|
182
|
+
ndcg_at_k_delta=(c.ndcg_at_k if c else 0.0) - (b.ndcg_at_k if b else 0.0),
|
|
183
|
+
mrr_delta=(c.mrr if c else 0.0) - (b.mrr if b else 0.0),
|
|
184
|
+
recall_at_k_delta=(c.recall_at_k if c else 0.0)
|
|
185
|
+
- (b.recall_at_k if b else 0.0),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
base_themes = {r.person: r for r in baseline.themes_results}
|
|
190
|
+
curr_themes = {r.person: r for r in current.themes_results}
|
|
191
|
+
per_themes: list[GraphThemesDiff] = []
|
|
192
|
+
for person in dict.fromkeys(list(base_themes) + list(curr_themes)):
|
|
193
|
+
b_t = base_themes.get(person)
|
|
194
|
+
c_t = curr_themes.get(person)
|
|
195
|
+
per_themes.append(
|
|
196
|
+
GraphThemesDiff(
|
|
197
|
+
person=person,
|
|
198
|
+
precision_delta=(c_t.precision if c_t else 0.0)
|
|
199
|
+
- (b_t.precision if b_t else 0.0),
|
|
200
|
+
recall_delta=(c_t.recall if c_t else 0.0) - (b_t.recall if b_t else 0.0),
|
|
201
|
+
f1_delta=(c_t.f1 if c_t else 0.0) - (b_t.f1 if b_t else 0.0),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return GraphBaselineDiff(
|
|
206
|
+
per_doc=per_doc,
|
|
207
|
+
per_themes=per_themes,
|
|
208
|
+
mean_local_ndcg_at_k_delta=current.mean_local_ndcg_at_k
|
|
209
|
+
- baseline.mean_local_ndcg_at_k,
|
|
210
|
+
mean_local_mrr_delta=current.mean_local_mrr - baseline.mean_local_mrr,
|
|
211
|
+
mean_local_recall_at_k_delta=current.mean_local_recall_at_k
|
|
212
|
+
- baseline.mean_local_recall_at_k,
|
|
213
|
+
mean_fuse_ndcg_at_k_delta=current.mean_fuse_ndcg_at_k
|
|
214
|
+
- baseline.mean_fuse_ndcg_at_k,
|
|
215
|
+
mean_fuse_mrr_delta=current.mean_fuse_mrr - baseline.mean_fuse_mrr,
|
|
216
|
+
mean_fuse_recall_at_k_delta=current.mean_fuse_recall_at_k
|
|
217
|
+
- baseline.mean_fuse_recall_at_k,
|
|
218
|
+
mean_themes_precision_delta=current.mean_themes_precision
|
|
219
|
+
- baseline.mean_themes_precision,
|
|
220
|
+
mean_themes_recall_delta=current.mean_themes_recall
|
|
221
|
+
- baseline.mean_themes_recall,
|
|
222
|
+
mean_themes_f1_delta=current.mean_themes_f1 - baseline.mean_themes_f1,
|
|
223
|
+
config_signature_changed=baseline.config_signature != current.config_signature,
|
|
224
|
+
baseline_signature=dict(baseline.config_signature),
|
|
225
|
+
current_signature=dict(current.config_signature),
|
|
226
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Graph-retrieval eval scorers (wave G2-j, GraphRAG; spec §6/§6b).
|
|
2
|
+
|
|
3
|
+
Scores the two G2 graph-retrieval paths against synthetic golden expectations:
|
|
4
|
+
|
|
5
|
+
* **Local (entity-centric, spec §6a).** ``graph_rag_search(mode='local')``
|
|
6
|
+
returns a *ranked document list* (``GraphContext.docs`` — reused
|
|
7
|
+
:class:`brain.search.SearchResult`s). A ranked doc list is exactly what the
|
|
8
|
+
existing ranking metrics measure, so :func:`score_local_docs` **reuses**
|
|
9
|
+
:func:`brain.eval.metrics.ndcg_at_k` / :func:`~brain.eval.metrics.mrr` /
|
|
10
|
+
:func:`~brain.eval.metrics.recall_at_k` — no new ranking metric is invented.
|
|
11
|
+
|
|
12
|
+
* **Themes-with-X (spec §6b — the HEADLINE).** ``graph_rag_search(mode='themes')``
|
|
13
|
+
returns ``ThemeGroup``s — *sets of co-occurring entity clusters*, not a ranked
|
|
14
|
+
document list. nDCG/MRR/Recall over doc IDs cannot express "did the right
|
|
15
|
+
*clusters* surface", so themes need a small graph-appropriate scorer:
|
|
16
|
+
:func:`score_themes` matches each expected theme keyset to a predicted theme
|
|
17
|
+
keyset by best Jaccard overlap (greedy, deterministic) above a threshold, then
|
|
18
|
+
reports set precision / recall / F1 over the matched clusters. This is the
|
|
19
|
+
cluster-grouping analogue of the concept-set F1 in
|
|
20
|
+
:mod:`brain.eval.concept_extraction`.
|
|
21
|
+
|
|
22
|
+
Both scorers are **pure** (no DB, no Ollama, no I/O) and unit-testable with
|
|
23
|
+
hand-constructed inputs. The end-to-end graph-retrieval eval test builds a small
|
|
24
|
+
synthetic AGE corpus (via ``reconcile_document`` on the test DB), runs
|
|
25
|
+
``graph_rag_search``, and feeds the results here.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
from brain.set_similarity import jaccard
|
|
33
|
+
|
|
34
|
+
from .metrics import mrr as _mrr
|
|
35
|
+
from .metrics import ndcg_at_k as _ndcg_at_k
|
|
36
|
+
from .metrics import recall_at_k as _recall_at_k
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DEFAULT_THEME_JACCARD",
|
|
40
|
+
"LocalRetrievalScore",
|
|
41
|
+
"ThemeRetrievalScore",
|
|
42
|
+
"score_local_docs",
|
|
43
|
+
"score_themes",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Default minimum Jaccard overlap for an expected theme keyset to count as
|
|
47
|
+
# "matched" by a predicted theme keyset. 0.5 means the two clusters must share
|
|
48
|
+
# the majority of their combined entities — strict enough that a one-entity
|
|
49
|
+
# accidental overlap between otherwise-different clusters does not count.
|
|
50
|
+
DEFAULT_THEME_JACCARD = 0.5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class LocalRetrievalScore:
|
|
55
|
+
"""nDCG@k / MRR / Recall@k for one local graph-retrieval query.
|
|
56
|
+
|
|
57
|
+
A thin bundle of the three reused ranking metrics scored over the local
|
|
58
|
+
path's ranked ``GraphContext.docs`` document IDs against the expected
|
|
59
|
+
document-ID set.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
ndcg_at_k: float
|
|
63
|
+
mrr: float
|
|
64
|
+
recall_at_k: float
|
|
65
|
+
ndcg_k: int
|
|
66
|
+
recall_k: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ThemeRetrievalScore:
|
|
71
|
+
"""Set precision / recall / F1 for one themes-with-X query.
|
|
72
|
+
|
|
73
|
+
Computed by greedy best-Jaccard matching of predicted theme keysets to
|
|
74
|
+
expected theme keysets above :data:`DEFAULT_THEME_JACCARD`. ``matched`` is the
|
|
75
|
+
number of expected themes matched; ``n_expected`` / ``n_actual`` are the
|
|
76
|
+
cluster counts.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
precision: float
|
|
80
|
+
recall: float
|
|
81
|
+
f1: float
|
|
82
|
+
matched: int
|
|
83
|
+
n_expected: int
|
|
84
|
+
n_actual: int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def score_local_docs(
|
|
88
|
+
actual_doc_ids: Sequence[str],
|
|
89
|
+
expected_doc_ids: Iterable[str],
|
|
90
|
+
*,
|
|
91
|
+
ndcg_k: int = 5,
|
|
92
|
+
recall_k: int = 20,
|
|
93
|
+
) -> LocalRetrievalScore:
|
|
94
|
+
"""Score a local query's ranked doc IDs with the reused ranking metrics.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
actual_doc_ids: Document IDs from ``GraphContext.docs``, in rank order.
|
|
98
|
+
expected_doc_ids: The relevant document IDs (order-independent).
|
|
99
|
+
ndcg_k: Cutoff for nDCG.
|
|
100
|
+
recall_k: Cutoff for recall.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A :class:`LocalRetrievalScore`.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
EvalMetricError: When ``expected_doc_ids`` is empty (propagated from the
|
|
107
|
+
underlying ranking metrics).
|
|
108
|
+
"""
|
|
109
|
+
expected = list(expected_doc_ids)
|
|
110
|
+
return LocalRetrievalScore(
|
|
111
|
+
ndcg_at_k=_ndcg_at_k(actual_doc_ids, expected, k=ndcg_k),
|
|
112
|
+
mrr=_mrr(actual_doc_ids, expected),
|
|
113
|
+
recall_at_k=_recall_at_k(actual_doc_ids, expected, k=recall_k),
|
|
114
|
+
ndcg_k=ndcg_k,
|
|
115
|
+
recall_k=recall_k,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _to_keysets(themes: Iterable[Iterable[str]]) -> list[frozenset[str]]:
|
|
120
|
+
"""Normalize theme entity-key iterables to lower-cased frozensets.
|
|
121
|
+
|
|
122
|
+
Empty clusters are dropped (a themeless group cannot be matched). Keys are
|
|
123
|
+
lower-cased + whitespace-collapsed to match the catalog ``canonical_key``.
|
|
124
|
+
"""
|
|
125
|
+
out: list[frozenset[str]] = []
|
|
126
|
+
for theme in themes:
|
|
127
|
+
keys = {" ".join(str(k).lower().split()) for k in theme}
|
|
128
|
+
keys.discard("")
|
|
129
|
+
if keys:
|
|
130
|
+
out.append(frozenset(keys))
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def score_themes(
|
|
135
|
+
actual_themes: Iterable[Iterable[str]],
|
|
136
|
+
expected_themes: Iterable[Iterable[str]],
|
|
137
|
+
*,
|
|
138
|
+
jaccard_threshold: float = DEFAULT_THEME_JACCARD,
|
|
139
|
+
) -> ThemeRetrievalScore:
|
|
140
|
+
"""Greedy best-Jaccard set precision / recall / F1 over theme clusters.
|
|
141
|
+
|
|
142
|
+
Each predicted / expected theme is a set of entity ``canonical_key``s. Every
|
|
143
|
+
``(expected, actual)`` pair with Jaccard ``>= jaccard_threshold`` is a
|
|
144
|
+
candidate match; candidates are consumed greedily highest-Jaccard-first (ties
|
|
145
|
+
broken deterministically by index) so each expected and each actual theme is
|
|
146
|
+
matched at most once. ``recall = matched / n_expected``,
|
|
147
|
+
``precision = matched / n_actual``, ``f1`` is their harmonic mean.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
actual_themes: Predicted theme keysets (e.g. from ``GraphContext.themes``).
|
|
151
|
+
expected_themes: The expected theme keysets.
|
|
152
|
+
jaccard_threshold: Minimum overlap for a match (default
|
|
153
|
+
:data:`DEFAULT_THEME_JACCARD`).
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A :class:`ThemeRetrievalScore`. With no expected and no actual themes,
|
|
157
|
+
precision/recall/F1 are all 1.0 (vacuously correct); when exactly one
|
|
158
|
+
side is empty they are 0.0.
|
|
159
|
+
"""
|
|
160
|
+
expected = _to_keysets(expected_themes)
|
|
161
|
+
actual = _to_keysets(actual_themes)
|
|
162
|
+
n_expected = len(expected)
|
|
163
|
+
n_actual = len(actual)
|
|
164
|
+
|
|
165
|
+
if n_expected == 0 and n_actual == 0:
|
|
166
|
+
return ThemeRetrievalScore(1.0, 1.0, 1.0, 0, 0, 0)
|
|
167
|
+
|
|
168
|
+
candidates: list[tuple[float, int, int]] = []
|
|
169
|
+
for ei, exp in enumerate(expected):
|
|
170
|
+
for ai, act in enumerate(actual):
|
|
171
|
+
score = jaccard(exp, act)
|
|
172
|
+
if score >= jaccard_threshold:
|
|
173
|
+
candidates.append((score, ei, ai))
|
|
174
|
+
# Highest Jaccard first; ties → lowest expected index, then lowest actual
|
|
175
|
+
# index, so the matching is deterministic across runs.
|
|
176
|
+
candidates.sort(key=lambda c: (-c[0], c[1], c[2]))
|
|
177
|
+
|
|
178
|
+
used_expected: set[int] = set()
|
|
179
|
+
used_actual: set[int] = set()
|
|
180
|
+
matched = 0
|
|
181
|
+
for _score, ei, ai in candidates:
|
|
182
|
+
if ei in used_expected or ai in used_actual:
|
|
183
|
+
continue
|
|
184
|
+
used_expected.add(ei)
|
|
185
|
+
used_actual.add(ai)
|
|
186
|
+
matched += 1
|
|
187
|
+
|
|
188
|
+
recall = matched / n_expected if n_expected > 0 else 0.0
|
|
189
|
+
precision = matched / n_actual if n_actual > 0 else 0.0
|
|
190
|
+
f1 = (
|
|
191
|
+
2 * precision * recall / (precision + recall)
|
|
192
|
+
if (precision + recall) > 0
|
|
193
|
+
else 0.0
|
|
194
|
+
)
|
|
195
|
+
return ThemeRetrievalScore(
|
|
196
|
+
precision=precision,
|
|
197
|
+
recall=recall,
|
|
198
|
+
f1=f1,
|
|
199
|
+
matched=matched,
|
|
200
|
+
n_expected=n_expected,
|
|
201
|
+
n_actual=n_actual,
|
|
202
|
+
)
|