Skip to content

Commit ff51c35

Browse files
committed
feat: score adaptive memory promotion
1 parent 063d372 commit ff51c35

12 files changed

Lines changed: 770 additions & 43 deletions

ARCHITECTURE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ records rendered into the final `<memory-data>` count. Same-provider/session/day
8484
replays are idempotent, and manual recalls without a consumer identity do not
8585
count. Explicit and adaptive memory pages do not feed their own counters, superseded
8686
sources are excluded, and each source's workspace remains the counting scope.
87+
Passing the hard gates is necessary but not sufficient. An explainable score must
88+
also meet `adaptive_memory_min_score` (default `0.65`). Its weighted contributions
89+
are session diversity `0.30`, UTC-day persistence `0.25`, final-context injection
90+
recurrence `0.25`, query-backed session ratio `0.10`, and provider diversity
91+
`0.10`. The first three ratios saturate at twice their configured hard minimum;
92+
provider diversity saturates at two providers. Search-only rows with
93+
`injected = 0` contribute to no count or score. Only direct search hits contribute
94+
to the query-backed ratio; one-hop related and recent-fallback records do not.
95+
The score, threshold, and weighted components are persisted in both the adaptive
96+
Markdown evidence block and document metadata, so promotion remains auditable
97+
without rerunning historical queries. Adaptive publication is first-writer-wins:
98+
the SQLite transaction selects one winner and only its in-transaction publication
99+
callback writes the deterministic Markdown path. If publication or commit fails,
100+
filesystem compensation runs before the SQLite write lock is released; it performs
101+
filesystem-only work to avoid self-deadlock and prolonged writer starvation. This
102+
prevents concurrent writers from mixing file evidence and metadata or deleting a
103+
later winner's file.
104+
The default score is an initial deterministic policy, not a learned probability.
105+
Legacy config files receive `0.65`; setting the threshold to `0` restores
106+
hard-gate-only behavior, and pending candidates are reconsidered on their next use.
107+
This score measures repeated utility, not truth or correctness.
87108
A later source supersession is propagated to its adaptive derivative so stale
88109
retained evidence remains hidden. Old usage rows are pruned as new usage arrives.
89110

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ The project follows [Semantic Versioning](https://semver.org/).
1919
- English, Korean, Japanese, and Simplified Chinese documentation distinguishing
2020
90-day short-term evidence, adaptive long-term memory, and explicit long-term
2121
memory.
22+
- Explainable adaptive-promotion scoring with a default `0.65` threshold across
23+
session diversity, UTC-day persistence, final-context injection recurrence,
24+
query-backed use, and provider diversity. Hard safety gates remain mandatory;
25+
non-injected search hits contribute nothing, and each promoted Markdown page
26+
and document metadata records the score, threshold, and weighted components.
27+
- Direct-search provenance for query-backed scoring; related and recent-fallback
28+
context no longer receives search credit.
29+
- First-writer-wins adaptive publication keeps concurrent Markdown evidence and
30+
SQLite promotion metadata from different attempts from being mixed; rollback
31+
compensation runs before releasing the SQLite writer lock.
2232

2333
### Changed
2434

README.ja.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ handoff が異なる UTC 日付で 3 日以上、異なる consumer provider/ses
121121
利用回数を合算することもありません。昇格後に source が superseded された場合、
122122
派生した adaptive memory も recall から隠します。
123123

124+
これらのハードゲートを満たすだけでは昇格しません。WikiBrain は、説明可能な昇格
125+
スコアがデフォルトの `0.65` 以上であることも要求します。スコアは session の多様性
126+
30%、異なる UTC 日にわたる継続性 25%、最終コンテキストへの反復注入 25%、明示的な
127+
query に基づく consumer session の割合 10%、consumer provider の多様性 10%を
128+
合算します。反復要素は設定したハード最小値の 2 倍で飽和し、provider の多様性は
129+
2 provider で飽和します。最終コンテキストに注入されなかった検索結果は寄与しません。
130+
昇格ページと document metadata にはスコア、しきい値、重み付き要素を記録し、
131+
`adaptive_memory_min_score` で 0 から 1 のしきい値を調整できます。query-backed として
132+
数えるのは明示的な検索の direct hit だけで、related と recent fallback は数えません。
133+
デフォルト値は学習済み確率ではなく、決定的な初期ポリシーです。既存 config にも `0.65`
134+
が適用されます。以前の hard-gate-only 動作を維持するには、しきい値を `0` にします。
135+
pending candidate は次回の利用時に再評価されます。
136+
124137
昇格では source で確認した証拠を最大 2,000 文字だけ新しい Markdown ページへ
125138
保存し、source 文書 ID、利用回数、昇格時刻、`memory_kind: adaptive` を記録します。
126139
これは繰り返し有用だったコンテキストであり、内容が真実だという自動判定では

README.ko.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ identity가 없는 수동 `brainctl recall`은 세지 않습니다. 최종 `<mem
139139
다른 workspace의 사용량도 합치지 않습니다. 승격 뒤 source가 superseded되면 파생된
140140
adaptive memory도 recall에서 숨깁니다.
141141

142+
이 hard gate를 통과하는 것만으로는 충분하지 않습니다. WikiBrain은 설명 가능한 승격
143+
점수가 기본 `0.65` 이상이어야 한다고 추가로 요구합니다. 점수는 session 다양성 30%,
144+
서로 다른 UTC 날짜에 걸친 지속성 25%, 최종 맥락의 반복 주입 25%, 명시적 query에
145+
기반한 consumer session 비율 10%, consumer provider 다양성 10%를 합산합니다. 반복
146+
항목은 설정된 hard minimum의 두 배에서 포화하고 provider 다양성은 두 provider에서
147+
포화합니다. 최종 맥락에 주입되지 않은 검색 결과는 점수에 기여하지 않습니다. 승격된
148+
페이지와 document metadata에는 점수·threshold·가중 component를 함께 기록하며,
149+
`adaptive_memory_min_score`로 0부터 1 사이의 threshold를 조정할 수 있습니다. 명시적
150+
검색의 direct hit만 query-backed로 세며 related·recent fallback record는 세지 않습니다.
151+
기본값은 학습된 확률이 아니라 결정적인 초기 정책입니다. 기존 config도 `0.65`를 적용하며,
152+
이전 hard-gate-only 동작을 유지하려면 threshold를 `0`으로 설정합니다. pending candidate는
153+
다음 사용 시 다시 평가됩니다.
154+
142155
승격 시 source에서 확인한 근거를 최대 2,000자만 새 Markdown 페이지에 저장하고,
143156
source 문서 ID, 사용 횟수, 승격 시각, `memory_kind: adaptive`를 함께 기록합니다.
144157
이는 반복해서 유용했던 맥락이지 내용이 참이라는 자동 판정이 아닙니다. 원래의

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ not count toward their own promotion, superseded evidence is ineligible, and
140140
workspace counters never mix. If a promoted source is superseded later, its
141141
adaptive derivative is hidden from recall too.
142142

143+
Meeting those hard gates is necessary but not sufficient. WikiBrain also requires
144+
an explainable promotion score of at least `0.65`. The score combines session
145+
diversity (30%), persistence across UTC days (25%), repeated final-context
146+
injection (25%), the share of consumer sessions backed by an explicit query
147+
(10%), and consumer-provider diversity (10%). Repetition components saturate at
148+
twice their configured hard minimum, and provider diversity saturates at two
149+
providers. Non-injected search hits contribute nothing. The score, threshold,
150+
and weighted components are written to the promoted page and document metadata;
151+
`adaptive_memory_min_score` configures the threshold from 0 to 1. Only direct
152+
search hits count as query-backed; related and recent-fallback records do not.
153+
The default is a deterministic initial policy, not a learned probability. Existing
154+
config files adopt `0.65`; set the threshold to `0` to retain the former hard-gate-only
155+
behavior. Pending candidates are reconsidered on their next use.
156+
143157
Promotion writes at most 2,000 characters of the source-verified evidence to a
144158
new Markdown page with the source document ID, usage counts, promotion time,
145159
and `memory_kind: adaptive`. It is retained context, not a declaration that the

README.zh-CN.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ UTC 日期、3 个不同的 consumer provider/session pair 中实际进入最终
131131
superseded 证据没有资格,使用量也不会跨 workspace 合并。若 source 在提升后被
132132
superseded,其派生的 adaptive memory 也会从 recall 中隐藏。
133133

134+
仅满足这些硬门槛还不足以提升。WikiBrain 还要求可解释的提升分数达到默认值
135+
`0.65`。该分数由 session 多样性(30%)、跨不同 UTC 日期的持续性(25%)、最终
136+
上下文中的重复注入(25%)、由显式 query 支持的 consumer session 比例(10%)和
137+
consumer provider 多样性(10%)组成。重复项在配置的硬门槛两倍处饱和,provider
138+
多样性在两个 provider 处饱和。未进入最终上下文的搜索结果不贡献分数。提升后的页面
139+
和 document metadata 会记录分数、阈值及加权分项;可通过
140+
`adaptive_memory_min_score` 在 0 到 1 之间调整阈值。只有显式搜索的 direct hit
141+
计入 query-backed,related 和 recent fallback 记录不计入。默认值是确定性的初始策略,
142+
并非学习得到的概率。已有 config 也会采用 `0.65`;若要保留此前仅使用硬门槛的行为,
143+
请将阈值设为 `0`。pending candidate 会在下次使用时重新评估。
144+
134145
提升时只把经 source 验证的证据中最多 2,000 个字符写入新的 Markdown 页面,并
135146
记录 source 文档 ID、使用次数、提升时间及 `memory_kind: adaptive`。它表示反复
136147
有用的上下文,不代表系统自动断言其内容为真。原始的 90 天证据过期后,这个较小

src/wikibrain/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class BrainConfig:
7474
adaptive_memory_min_sessions: int = 3
7575
adaptive_memory_min_days: int = 3
7676
adaptive_memory_min_injections: int = 2
77+
adaptive_memory_min_score: float = 0.65
7778
adaptive_memory_max_chars: int = 2_000
7879
wikimap_command: str = "wikimap"
7980
update_on_stop: bool = True

src/wikibrain/curation.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,25 @@ def remember(
321321
"""
322322
target_path = self.config.vault_path / relative
323323
previous_content = (
324-
target_path.read_text(encoding="utf-8") if target_path.exists() else None
324+
target_path.read_text(encoding="utf-8")
325+
if memory_kind != "adaptive" and target_path.exists()
326+
else None
325327
)
326-
path = self._write(relative, content)
328+
adaptive_previous_content: str | None = None
329+
path = target_path if memory_kind == "adaptive" else self._write(relative, content)
330+
331+
def publish_adaptive() -> None:
332+
nonlocal adaptive_previous_content
333+
adaptive_previous_content = (
334+
path.read_text(encoding="utf-8") if path.exists() else None
335+
)
336+
self._write(relative, content)
337+
338+
def rollback_adaptive_publish() -> None:
339+
if adaptive_previous_content is None:
340+
path.unlink(missing_ok=True)
341+
else:
342+
atomic_write_text(path, adaptive_previous_content)
327343

328344
def restore_failed_write() -> None:
329345
current = self.store.document(document_id)
@@ -350,9 +366,15 @@ def restore_failed_write() -> None:
350366
**(registration_metadata or {}),
351367
},
352368
relations=relations,
369+
insert_only=memory_kind == "adaptive",
370+
publish=publish_adaptive if memory_kind == "adaptive" else None,
371+
rollback_publish=(
372+
rollback_adaptive_publish if memory_kind == "adaptive" else None
373+
),
353374
)
354375
except Exception:
355-
restore_failed_write()
376+
if memory_kind != "adaptive":
377+
restore_failed_write()
356378
raise
357379
if not registered:
358380
restore_failed_write()
@@ -391,13 +413,31 @@ def promote_adaptive(
391413
)
392414
title = f"Retained context: {title_line[:100]}"
393415
promoted_at = datetime.now(UTC).isoformat(timespec="milliseconds")
416+
promotion_score = float(usage.get("promotion_score", 0.0))
417+
promotion_score_threshold = float(
418+
usage.get("promotion_score_threshold", 0.0)
419+
)
420+
raw_components = usage.get("promotion_score_components", {})
421+
score_components = (
422+
{str(key): float(value) for key, value in raw_components.items()}
423+
if isinstance(raw_components, Mapping)
424+
else {}
425+
)
426+
component_lines = "".join(
427+
f" - {name.replace('_', ' ').title()}: {value:.3f}\n"
428+
for name, value in score_components.items()
429+
)
394430
body = (
395431
bounded
396432
+ "\n\n## Automatic retention evidence\n\n"
397433
+ f"- Distinct sessions: {int(usage.get('distinct_sessions', 0))}\n"
434+
+ f"- Distinct providers: {int(usage.get('distinct_providers', 0))}\n"
398435
+ f"- Distinct days: {int(usage.get('distinct_days', 0))}\n"
399436
+ f"- Search sessions: {int(usage.get('search_sessions', 0))}\n"
400437
+ f"- Context injections: {int(usage.get('context_injections', 0))}\n"
438+
+ f"- Promotion score: {promotion_score:.3f} / "
439+
+ f"{promotion_score_threshold:.3f}\n"
440+
+ ("- Score components:\n" + component_lines if component_lines else "")
401441
+ f"- Source document: `{source_document_id}`\n"
402442
)
403443
created_id, path = self.remember(
@@ -418,8 +458,12 @@ def promote_adaptive(
418458
"adaptive_source_document_id": source_document_id,
419459
"promoted_at": promoted_at,
420460
"last_used_at": str(usage.get("last_used_at") or promoted_at),
461+
"promotion_score": promotion_score,
462+
"promotion_score_threshold": promotion_score_threshold,
463+
"promotion_score_components": score_components,
421464
"promotion_reason": {
422465
"distinct_sessions": int(usage.get("distinct_sessions", 0)),
466+
"distinct_providers": int(usage.get("distinct_providers", 0)),
423467
"distinct_days": int(usage.get("distinct_days", 0)),
424468
"search_sessions": int(usage.get("search_sessions", 0)),
425469
"context_injections": int(

src/wikibrain/recall.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def _render_record(
7777
captured_at: str,
7878
evidence: str,
7979
relations: list[tuple[str, str]] | None = None,
80+
retrieval_source: str | None = None,
8081
truncated: bool = False,
8182
) -> str:
8283
line_attribute = f' line="{line}"' if line is not None else ""
@@ -241,6 +242,7 @@ def context(
241242
snippet=hit.snippet,
242243
query=query,
243244
),
245+
"retrieval_source": "search",
244246
"relations": [
245247
(
246248
str(relation["relation_type"]),
@@ -298,6 +300,7 @@ def context(
298300
"line": None,
299301
"captured_at": str(related_row["created_at"]),
300302
"evidence": snippet,
303+
"retrieval_source": "related",
301304
"relations": [
302305
(
303306
str(relation["relation_type"]),
@@ -339,6 +342,7 @@ def context(
339342
"line": None,
340343
"captured_at": str(row["created_at"]),
341344
"evidence": snippet,
345+
"retrieval_source": "recent",
342346
"relations": [
343347
(
344348
str(relation["relation_type"]),
@@ -416,12 +420,13 @@ def context(
416420
str(record["document_id"]),
417421
consumer_provider=provider,
418422
consumer_session_id=session_id,
419-
searched=bool(query),
423+
searched=record.get("retrieval_source") == "search",
420424
injected=True,
421425
window_days=self.config.adaptive_memory_window_days,
422426
min_sessions=self.config.adaptive_memory_min_sessions,
423427
min_days=self.config.adaptive_memory_min_days,
424428
min_injections=self.config.adaptive_memory_min_injections,
429+
min_score=self.config.adaptive_memory_min_score,
425430
)
426431
if usage["eligible"]:
427432
curator = curator or Curator(

0 commit comments

Comments
 (0)