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,281 @@
|
|
|
1
|
+
"""Answer-quality eval for `brain ask` (Plan 06, Phase 3).
|
|
2
|
+
|
|
3
|
+
Measures synthesized answers against a golden fact-set with deterministic,
|
|
4
|
+
local-only scoring — substring + significant-token overlap, NO LLM judge in the
|
|
5
|
+
loop — so the harness stays reproducible and never requires Ollama to *score*
|
|
6
|
+
(the live ``ask_fn`` that produces answers may, but that is the caller's
|
|
7
|
+
concern). Mirrors the hybrid-search eval package's shape (``corpus`` loader +
|
|
8
|
+
dataclass report) so the two read consistently.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from .errors import EvalCorpusError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ..ask import AskResult
|
|
25
|
+
|
|
26
|
+
# Default answer-corpus path: eval/ -> brain/ -> src/ -> repo root, then into
|
|
27
|
+
# tests/eval/. Committed (unlike the hybrid golden corpus) because it is fully
|
|
28
|
+
# synthetic — no real doc IDs, names, or employers (CLAUDE.md rule 15).
|
|
29
|
+
_DEFAULT_ANSWER_CORPUS_PATH: Path = (
|
|
30
|
+
Path(__file__).resolve().parent.parent.parent.parent
|
|
31
|
+
/ "tests"
|
|
32
|
+
/ "eval"
|
|
33
|
+
/ "answer_corpus.yaml"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_VALID_ANSWER_CATEGORIES: frozenset[str] = frozenset(
|
|
37
|
+
("synthesis", "multi-hop", "person", "timeline")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_REQUIRED_ANSWER_FIELDS: frozenset[str] = frozenset(
|
|
41
|
+
("question", "expected_facts", "category")
|
|
42
|
+
)
|
|
43
|
+
_ALLOWED_ANSWER_FIELDS: frozenset[str] = frozenset(
|
|
44
|
+
("question", "expected_facts", "category", "notes")
|
|
45
|
+
)
|
|
46
|
+
_ANSWER_CORPUS_VERSION = 1
|
|
47
|
+
|
|
48
|
+
# A fact counts as covered when at least this fraction of its significant
|
|
49
|
+
# (non-stopword) tokens appear in the answer — a majority by default. Single
|
|
50
|
+
# distinctive tokens (ratio 1.0) and exact phrase substrings always pass.
|
|
51
|
+
_FACT_COVERAGE_THRESHOLD = 0.5
|
|
52
|
+
|
|
53
|
+
# Minimal English stopword set for significant-token extraction. Deliberately
|
|
54
|
+
# small — only the highest-frequency function words — so distinctive content
|
|
55
|
+
# words (the signal we score on) survive.
|
|
56
|
+
_STOPWORDS: frozenset[str] = frozenset(
|
|
57
|
+
(
|
|
58
|
+
"the", "a", "an", "of", "to", "and", "or", "in", "on", "for", "with",
|
|
59
|
+
"is", "are", "was", "were", "be", "been", "my", "i", "we", "you",
|
|
60
|
+
"that", "this", "it", "as", "at", "by", "from", "about", "into",
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class AnswerEvalCase:
|
|
69
|
+
"""One golden Q&A case from the answer corpus."""
|
|
70
|
+
|
|
71
|
+
question: str
|
|
72
|
+
expected_facts: list[str] # short synthetic phrases; all should appear/be implied
|
|
73
|
+
category: str # one of _VALID_ANSWER_CATEGORIES
|
|
74
|
+
notes: str = "" # human-readable rationale, not graded
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class AnswerScore:
|
|
79
|
+
"""Per-case answer-quality score."""
|
|
80
|
+
|
|
81
|
+
question: str
|
|
82
|
+
category: str
|
|
83
|
+
fact_recall: float # fraction of expected_facts covered by the answer
|
|
84
|
+
citation_count: int # number of distinct citations the answer produced
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class AnswerEvalReport:
|
|
89
|
+
"""Aggregate answer-eval result over a corpus run."""
|
|
90
|
+
|
|
91
|
+
scores: list[AnswerScore]
|
|
92
|
+
mean_fact_recall: float
|
|
93
|
+
mean_citation_count: float
|
|
94
|
+
timestamp: str # ISO-8601
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _significant_tokens(text: str) -> list[str]:
|
|
98
|
+
"""Lowercase content tokens with stopwords removed."""
|
|
99
|
+
return [t for t in _TOKEN_RE.findall(text.lower()) if t not in _STOPWORDS]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fact_covered(answer_lower: str, answer_tokens: set[str], fact: str) -> bool:
|
|
103
|
+
"""Return True if ``fact`` is covered by the answer.
|
|
104
|
+
|
|
105
|
+
Covered when the exact (lowercased) fact phrase is a substring of the
|
|
106
|
+
answer, OR at least :data:`_FACT_COVERAGE_THRESHOLD` of the fact's
|
|
107
|
+
significant tokens appear in the answer's token set.
|
|
108
|
+
"""
|
|
109
|
+
if fact.strip().lower() in answer_lower:
|
|
110
|
+
return True
|
|
111
|
+
fact_tokens = _significant_tokens(fact)
|
|
112
|
+
if not fact_tokens:
|
|
113
|
+
# No content words to compare — fall back to the substring result above.
|
|
114
|
+
return False
|
|
115
|
+
present = sum(1 for t in fact_tokens if t in answer_tokens)
|
|
116
|
+
return present / len(fact_tokens) >= _FACT_COVERAGE_THRESHOLD
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def score_answer(answer: str, expected_facts: list[str]) -> float:
|
|
120
|
+
"""Fraction of ``expected_facts`` covered by ``answer`` in ``[0.0, 1.0]``.
|
|
121
|
+
|
|
122
|
+
Deterministic substring + significant-token-overlap matching (no LLM). An
|
|
123
|
+
empty ``expected_facts`` list scores ``1.0`` (nothing to miss).
|
|
124
|
+
"""
|
|
125
|
+
if not expected_facts:
|
|
126
|
+
return 1.0
|
|
127
|
+
answer_lower = answer.lower()
|
|
128
|
+
answer_tokens = set(_significant_tokens(answer))
|
|
129
|
+
covered = sum(
|
|
130
|
+
1
|
|
131
|
+
for fact in expected_facts
|
|
132
|
+
if _fact_covered(answer_lower, answer_tokens, fact)
|
|
133
|
+
)
|
|
134
|
+
return covered / len(expected_facts)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_answer_eval(
|
|
138
|
+
cases: list[AnswerEvalCase],
|
|
139
|
+
ask_fn: Callable[[str], AskResult],
|
|
140
|
+
*,
|
|
141
|
+
now: datetime | None = None,
|
|
142
|
+
) -> AnswerEvalReport:
|
|
143
|
+
"""Run ``ask_fn`` over every case and score the answers.
|
|
144
|
+
|
|
145
|
+
``ask_fn`` maps a question to an :class:`~brain.ask.AskResult` (typically a
|
|
146
|
+
closure over ``ask_no_loop`` against the live brain). ``now`` overrides the
|
|
147
|
+
report timestamp (defaults to the current UTC time) so tests stay
|
|
148
|
+
deterministic.
|
|
149
|
+
"""
|
|
150
|
+
scores: list[AnswerScore] = []
|
|
151
|
+
for case in cases:
|
|
152
|
+
result = ask_fn(case.question)
|
|
153
|
+
scores.append(
|
|
154
|
+
AnswerScore(
|
|
155
|
+
question=case.question,
|
|
156
|
+
category=case.category,
|
|
157
|
+
fact_recall=score_answer(result.answer, case.expected_facts),
|
|
158
|
+
citation_count=len(result.citations),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
if scores:
|
|
162
|
+
mean_recall = sum(s.fact_recall for s in scores) / len(scores)
|
|
163
|
+
mean_citations = sum(s.citation_count for s in scores) / len(scores)
|
|
164
|
+
else:
|
|
165
|
+
mean_recall = 0.0
|
|
166
|
+
mean_citations = 0.0
|
|
167
|
+
stamp = (now or datetime.now(UTC)).isoformat()
|
|
168
|
+
return AnswerEvalReport(
|
|
169
|
+
scores=scores,
|
|
170
|
+
mean_fact_recall=mean_recall,
|
|
171
|
+
mean_citation_count=mean_citations,
|
|
172
|
+
timestamp=stamp,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_answer_corpus(path: Path | None = None) -> list[AnswerEvalCase]:
|
|
177
|
+
"""Load + validate the answer-eval corpus YAML.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
EvalCorpusError: missing/malformed file, version mismatch, unknown
|
|
181
|
+
category, missing required field, or empty ``expected_facts``.
|
|
182
|
+
"""
|
|
183
|
+
if path is None:
|
|
184
|
+
path = _DEFAULT_ANSWER_CORPUS_PATH
|
|
185
|
+
|
|
186
|
+
if not path.exists():
|
|
187
|
+
raise EvalCorpusError(f"answer corpus file not found: {path}")
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
191
|
+
except yaml.YAMLError as exc:
|
|
192
|
+
raise EvalCorpusError(
|
|
193
|
+
f"answer corpus YAML parse error in {path}: {exc}"
|
|
194
|
+
) from exc
|
|
195
|
+
|
|
196
|
+
if not isinstance(raw, dict):
|
|
197
|
+
raise EvalCorpusError(
|
|
198
|
+
f"answer corpus file must be a YAML mapping, got {type(raw).__name__}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
version = raw.get("version")
|
|
202
|
+
if version != _ANSWER_CORPUS_VERSION:
|
|
203
|
+
raise EvalCorpusError(
|
|
204
|
+
f"answer corpus version mismatch: expected {_ANSWER_CORPUS_VERSION}, "
|
|
205
|
+
f"got {version!r}."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
raw_cases = raw.get("cases")
|
|
209
|
+
if not isinstance(raw_cases, list):
|
|
210
|
+
raise EvalCorpusError("answer corpus file must have a 'cases' list")
|
|
211
|
+
|
|
212
|
+
cases: list[AnswerEvalCase] = []
|
|
213
|
+
for i, entry in enumerate(raw_cases):
|
|
214
|
+
if not isinstance(entry, dict):
|
|
215
|
+
raise EvalCorpusError(
|
|
216
|
+
f"answer corpus case #{i + 1} must be a YAML mapping, "
|
|
217
|
+
f"got {type(entry).__name__}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
missing = _REQUIRED_ANSWER_FIELDS - entry.keys()
|
|
221
|
+
if missing:
|
|
222
|
+
raise EvalCorpusError(
|
|
223
|
+
f"answer corpus case #{i + 1} is missing required field(s): "
|
|
224
|
+
f"{', '.join(sorted(missing))}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
unknown = entry.keys() - _ALLOWED_ANSWER_FIELDS
|
|
228
|
+
if unknown:
|
|
229
|
+
raise EvalCorpusError(
|
|
230
|
+
f"answer corpus case #{i + 1} has unknown field(s): "
|
|
231
|
+
f"{', '.join(sorted(unknown))}. "
|
|
232
|
+
f"Allowed: {', '.join(sorted(_ALLOWED_ANSWER_FIELDS))}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
category = entry["category"]
|
|
236
|
+
if category not in _VALID_ANSWER_CATEGORIES:
|
|
237
|
+
raise EvalCorpusError(
|
|
238
|
+
f"answer corpus case #{i + 1} has unknown category {category!r}. "
|
|
239
|
+
f"Valid: {', '.join(sorted(_VALID_ANSWER_CATEGORIES))}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
expected_facts = entry["expected_facts"]
|
|
243
|
+
if not isinstance(expected_facts, list):
|
|
244
|
+
raise EvalCorpusError(
|
|
245
|
+
f"answer corpus case #{i + 1} 'expected_facts' must be a list, "
|
|
246
|
+
f"got {type(expected_facts).__name__}"
|
|
247
|
+
)
|
|
248
|
+
if not expected_facts:
|
|
249
|
+
raise EvalCorpusError(
|
|
250
|
+
f"answer corpus case #{i + 1} ({entry['question']!r}) has empty "
|
|
251
|
+
f"'expected_facts'"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
cases.append(
|
|
255
|
+
AnswerEvalCase(
|
|
256
|
+
question=str(entry["question"]),
|
|
257
|
+
expected_facts=[str(f) for f in expected_facts],
|
|
258
|
+
category=category,
|
|
259
|
+
notes=str(entry.get("notes", "")),
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return cases
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def answer_eval_report_to_dict(report: AnswerEvalReport) -> dict[str, Any]:
|
|
267
|
+
"""JSON projection of an :class:`AnswerEvalReport` (CLI ``--json``)."""
|
|
268
|
+
return {
|
|
269
|
+
"mean_fact_recall": report.mean_fact_recall,
|
|
270
|
+
"mean_citation_count": report.mean_citation_count,
|
|
271
|
+
"timestamp": report.timestamp,
|
|
272
|
+
"scores": [
|
|
273
|
+
{
|
|
274
|
+
"question": s.question,
|
|
275
|
+
"category": s.category,
|
|
276
|
+
"fact_recall": s.fact_recall,
|
|
277
|
+
"citation_count": s.citation_count,
|
|
278
|
+
}
|
|
279
|
+
for s in report.scores
|
|
280
|
+
],
|
|
281
|
+
}
|
brain/eval/baseline.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Baseline save/load and diff helpers for eval reports.
|
|
2
|
+
|
|
3
|
+
Baselines are stored as JSON files (sorted keys, floats rounded to 4 decimals
|
|
4
|
+
for byte-stable diffs) via an atomic write so a crash can never leave a
|
|
5
|
+
half-written file. Baseline names are validated against a strict allowlist
|
|
6
|
+
pattern to prevent path traversal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..vault._atomic import atomic_write_text
|
|
18
|
+
from .errors import EvalBaselineError
|
|
19
|
+
from .runner import CategorySummary, EvalReport, EvalResult
|
|
20
|
+
|
|
21
|
+
_BASELINE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _assert_baseline_name(name: str) -> None:
|
|
25
|
+
"""Validate that *name* is safe to use as a filename component.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
EvalBaselineError: When *name* contains characters outside
|
|
29
|
+
``[A-Za-z0-9_-]`` (e.g. slashes, dots, spaces).
|
|
30
|
+
"""
|
|
31
|
+
if not _BASELINE_NAME_RE.fullmatch(name):
|
|
32
|
+
raise EvalBaselineError(
|
|
33
|
+
f"baseline name {name!r} is invalid; "
|
|
34
|
+
f"use only letters, digits, hyphens, and underscores"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _round_floats(obj: Any, decimals: int = 4) -> Any:
|
|
39
|
+
"""Recursively round all floats in a JSON-compatible structure."""
|
|
40
|
+
if isinstance(obj, float):
|
|
41
|
+
return round(obj, decimals)
|
|
42
|
+
if isinstance(obj, dict):
|
|
43
|
+
return {k: _round_floats(v, decimals) for k, v in obj.items()}
|
|
44
|
+
if isinstance(obj, list):
|
|
45
|
+
return [_round_floats(v, decimals) for v in obj]
|
|
46
|
+
return obj
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_baseline(report: EvalReport, *, path: Path) -> None:
|
|
50
|
+
"""Write *report* to *path* as JSON, atomically.
|
|
51
|
+
|
|
52
|
+
Keys are sorted and floats rounded to 4 decimal places so repeated
|
|
53
|
+
runs over the same corpus produce byte-stable diffs. The parent
|
|
54
|
+
directory is created if it does not exist.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
report: The eval report to persist.
|
|
58
|
+
path: Destination file path (typically under ``tests/eval/baselines/``).
|
|
59
|
+
"""
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
raw: dict[str, Any] = dataclasses.asdict(report)
|
|
62
|
+
raw = _round_floats(raw)
|
|
63
|
+
# datetime → ISO-8601 with Z suffix for UTC.
|
|
64
|
+
raw["generated_at"] = report.generated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
65
|
+
text = json.dumps(raw, sort_keys=True, default=str, indent=2) + "\n"
|
|
66
|
+
atomic_write_text(path, text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_baseline(path: Path) -> EvalReport:
|
|
70
|
+
"""Load an :class:`~brain.eval.runner.EvalReport` from a JSON baseline file.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
path: Path to the baseline JSON file.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
EvalBaselineError: When the file is missing, contains invalid JSON,
|
|
77
|
+
or has an unexpected structure.
|
|
78
|
+
"""
|
|
79
|
+
if not path.exists():
|
|
80
|
+
raise EvalBaselineError(f"baseline file not found: {path}")
|
|
81
|
+
try:
|
|
82
|
+
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
raise EvalBaselineError(
|
|
85
|
+
f"baseline file contains invalid JSON: {path}: {exc}"
|
|
86
|
+
) from exc
|
|
87
|
+
try:
|
|
88
|
+
results = [
|
|
89
|
+
EvalResult(
|
|
90
|
+
query=r["query"],
|
|
91
|
+
category=r["category"],
|
|
92
|
+
expected_doc_ids=list(r["expected_doc_ids"]),
|
|
93
|
+
actual_doc_ids=list(r["actual_doc_ids"]),
|
|
94
|
+
ndcg_at_5=float(r["ndcg_at_5"]),
|
|
95
|
+
mrr=float(r["mrr"]),
|
|
96
|
+
recall_at_20=float(r["recall_at_20"]),
|
|
97
|
+
)
|
|
98
|
+
for r in data["results"]
|
|
99
|
+
]
|
|
100
|
+
per_category = {
|
|
101
|
+
k: CategorySummary(
|
|
102
|
+
category=v["category"],
|
|
103
|
+
count=int(v["count"]),
|
|
104
|
+
mean_ndcg_at_5=float(v["mean_ndcg_at_5"]),
|
|
105
|
+
mean_mrr=float(v["mean_mrr"]),
|
|
106
|
+
mean_recall_at_20=float(v["mean_recall_at_20"]),
|
|
107
|
+
)
|
|
108
|
+
for k, v in data["per_category"].items()
|
|
109
|
+
}
|
|
110
|
+
generated_at_raw: str = data["generated_at"]
|
|
111
|
+
# Python 3.11+ accepts the "Z" suffix in fromisoformat.
|
|
112
|
+
generated_at = datetime.fromisoformat(generated_at_raw)
|
|
113
|
+
if generated_at.tzinfo is None:
|
|
114
|
+
generated_at = generated_at.replace(tzinfo=UTC)
|
|
115
|
+
return EvalReport(
|
|
116
|
+
results=results,
|
|
117
|
+
mean_ndcg_at_5=float(data["mean_ndcg_at_5"]),
|
|
118
|
+
mean_mrr=float(data["mean_mrr"]),
|
|
119
|
+
mean_recall_at_20=float(data["mean_recall_at_20"]),
|
|
120
|
+
per_category=per_category,
|
|
121
|
+
config_signature=dict(data["config_signature"]),
|
|
122
|
+
generated_at=generated_at,
|
|
123
|
+
)
|
|
124
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
125
|
+
raise EvalBaselineError(
|
|
126
|
+
f"baseline file has unexpected structure: {path}: {exc}"
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Diff helpers
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class QueryDiff:
|
|
137
|
+
"""Per-query metric delta between a baseline and a current run."""
|
|
138
|
+
|
|
139
|
+
query: str
|
|
140
|
+
category: str
|
|
141
|
+
ndcg_at_5_delta: float # current - baseline; negative = regression
|
|
142
|
+
mrr_delta: float
|
|
143
|
+
recall_at_20_delta: float
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class BaselineDiff:
|
|
148
|
+
"""Aggregate diff between a baseline :class:`EvalReport` and a current run.
|
|
149
|
+
|
|
150
|
+
Per-query deltas and the aggregate means cover the INTERSECTION of the two
|
|
151
|
+
runs' query sets (queries present in BOTH). Queries present in only one run
|
|
152
|
+
are surfaced separately in :attr:`added_queries` / :attr:`removed_queries`
|
|
153
|
+
and excluded from the aggregate, so a growing or shrinking query set never
|
|
154
|
+
dilutes a real regression toward 0 — the aggregate always compares
|
|
155
|
+
like-for-like. This is the input contract for the ``--fail-below`` gate.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
per_query: list[QueryDiff]
|
|
159
|
+
mean_ndcg_at_5_delta: float
|
|
160
|
+
mean_mrr_delta: float
|
|
161
|
+
mean_recall_at_20_delta: float
|
|
162
|
+
config_signature_changed: bool
|
|
163
|
+
baseline_signature: dict[str, Any]
|
|
164
|
+
current_signature: dict[str, Any]
|
|
165
|
+
# Query strings present in ``current`` but not ``baseline`` (added), and in
|
|
166
|
+
# ``baseline`` but not ``current`` (removed). Reported separately so the
|
|
167
|
+
# aggregate stays intersection-only; deterministic source order.
|
|
168
|
+
added_queries: list[str]
|
|
169
|
+
removed_queries: list[str]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def diff_reports(baseline: EvalReport, current: EvalReport) -> BaselineDiff:
|
|
173
|
+
"""Compute the delta between *baseline* and *current*.
|
|
174
|
+
|
|
175
|
+
Queries are matched by query string. Per-query deltas AND the aggregate mean
|
|
176
|
+
deltas are computed over the INTERSECTION of the two query sets (queries
|
|
177
|
+
present in BOTH reports), in the baseline's result order. Queries present in
|
|
178
|
+
only one report are surfaced separately as
|
|
179
|
+
:attr:`~BaselineDiff.added_queries` (in *current* only) /
|
|
180
|
+
:attr:`~BaselineDiff.removed_queries` (in *baseline* only) and excluded from
|
|
181
|
+
the aggregate — so a growing or shrinking query set never dilutes a real
|
|
182
|
+
regression toward 0 (an earlier union-with-0-fill averaged each added query's
|
|
183
|
+
``current - 0`` positive delta into the mean, masking regressions on the
|
|
184
|
+
shared queries).
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
baseline: The reference (earlier) eval report.
|
|
188
|
+
current: The new eval report to compare.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A :class:`BaselineDiff` whose per-query and aggregate deltas cover the
|
|
192
|
+
shared queries, plus the added/removed query id lists.
|
|
193
|
+
"""
|
|
194
|
+
# Build lookup dicts keyed by query string.
|
|
195
|
+
base_by_q = {r.query: r for r in baseline.results}
|
|
196
|
+
curr_by_q = {r.query: r for r in current.results}
|
|
197
|
+
|
|
198
|
+
# Per-query deltas over the SHARED queries only, in baseline result order.
|
|
199
|
+
per_query: list[QueryDiff] = []
|
|
200
|
+
for q_str, base_r in base_by_q.items():
|
|
201
|
+
curr_r = curr_by_q.get(q_str)
|
|
202
|
+
if curr_r is None:
|
|
203
|
+
continue # removed query — reported separately, not in the aggregate
|
|
204
|
+
per_query.append(
|
|
205
|
+
QueryDiff(
|
|
206
|
+
query=q_str,
|
|
207
|
+
category=curr_r.category,
|
|
208
|
+
ndcg_at_5_delta=curr_r.ndcg_at_5 - base_r.ndcg_at_5,
|
|
209
|
+
mrr_delta=curr_r.mrr - base_r.mrr,
|
|
210
|
+
recall_at_20_delta=curr_r.recall_at_20 - base_r.recall_at_20,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Query-set changes surfaced separately (deterministic source order): never
|
|
215
|
+
# folded into the aggregate.
|
|
216
|
+
added_queries = [q for q in curr_by_q if q not in base_by_q]
|
|
217
|
+
removed_queries = [q for q in base_by_q if q not in curr_by_q]
|
|
218
|
+
|
|
219
|
+
n = len(per_query)
|
|
220
|
+
mean_ndcg_delta = sum(d.ndcg_at_5_delta for d in per_query) / n if n else 0.0
|
|
221
|
+
mean_mrr_delta = sum(d.mrr_delta for d in per_query) / n if n else 0.0
|
|
222
|
+
mean_recall_delta = sum(d.recall_at_20_delta for d in per_query) / n if n else 0.0
|
|
223
|
+
|
|
224
|
+
return BaselineDiff(
|
|
225
|
+
per_query=per_query,
|
|
226
|
+
mean_ndcg_at_5_delta=mean_ndcg_delta,
|
|
227
|
+
mean_mrr_delta=mean_mrr_delta,
|
|
228
|
+
mean_recall_at_20_delta=mean_recall_delta,
|
|
229
|
+
config_signature_changed=baseline.config_signature != current.config_signature,
|
|
230
|
+
baseline_signature=dict(baseline.config_signature),
|
|
231
|
+
current_signature=dict(current.config_signature),
|
|
232
|
+
added_queries=added_queries,
|
|
233
|
+
removed_queries=removed_queries,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# ``--fail-below`` regression gate (Task 5.5)
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
# A mean metric "regresses" when its 4-decimal-ROUNDED delta is more negative
|
|
242
|
+
# than one unit at the baseline's 4-decimal serialization precision (``-1e-4``).
|
|
243
|
+
# Comparing the ROUNDED delta — not the raw float — keeps the boundary
|
|
244
|
+
# constructible: a delta of exactly ``-0.0001`` is NOT a regression (it equals
|
|
245
|
+
# the threshold), whereas ``-0.0002`` is. A raw-float compare would be
|
|
246
|
+
# IEEE-754-fragile right at that boundary. Uniform across all three metrics
|
|
247
|
+
# (nDCG@5 / MRR / Recall@20) — no per-metric overrides (CLAUDE.md decision).
|
|
248
|
+
_FAIL_BELOW_THRESHOLD: float = -1e-4
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def mean_metrics_regressed(diff: BaselineDiff) -> bool:
|
|
252
|
+
"""True iff any mean metric delta regressed past the ``--fail-below`` gate.
|
|
253
|
+
|
|
254
|
+
Checks ``round(delta, 4) < -1e-4`` for each of nDCG@5 / MRR / Recall@20 (the
|
|
255
|
+
aggregate over the shared query set — :func:`diff_reports` already excludes
|
|
256
|
+
added/removed queries so a changed corpus never dilutes a real regression).
|
|
257
|
+
"""
|
|
258
|
+
return any(
|
|
259
|
+
round(delta, 4) < _FAIL_BELOW_THRESHOLD
|
|
260
|
+
for delta in (
|
|
261
|
+
diff.mean_ndcg_at_5_delta,
|
|
262
|
+
diff.mean_mrr_delta,
|
|
263
|
+
diff.mean_recall_at_20_delta,
|
|
264
|
+
)
|
|
265
|
+
)
|