Skip to content

Commit bc88a4d

Browse files
feat(recall): SAG 启发的 ACT-R 扩散激活 + 关联实体索引(v0.1.17, schema 4.18)
借鉴 SAG 的"实体中介多跳召回"精髓,落地为 Innate 此前缺失的 ACT-R spreading activation(关联激活),补全激活模型(base-level + associative)。 - entities.rs:确定性实体抽取(无 LLM、无 regex),抽错误码/flag/路径/代码符号, 刻意忽略普通小写词(已由词法通道覆盖),保持实体区分度。 - chunk_entities 倒排表(schema 4.18 + 迁移回填);insert_chunk 在喂 chunks_fts 的同一写路径上抽实体,覆盖所有写者,spark 排除。 - recall expand_by_spreading:源激活来自 query 实体 + top-N 种子候选实体(2 跳), 按 a_e/fan(e) 扩散(ACT-R S_ji=S−ln(fan),fan_cap 丢弃泛实体,根治 SAG 抽取噪声); fused 新增第七项 w_spread。 - 三个 meta 旋钮 recall.w_spread(默认 0.0 灰度)/ spread_fan_cap / spread_seed_n, 权重为 0 时 expand 立即返回 → 热路径零成本零行为变化。 - 砍掉 SAG 的 event 层 / pgvector / LLM 入库依赖(与既定取舍冲突)。 182 测试全通过,eval 质量零回归(默认关闭);文档与 schema 版本同步。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8ce3248 commit bc88a4d

17 files changed

Lines changed: 666 additions & 16 deletions

File tree

.autoforge/specs/tech_stack.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
## Rust 核心
44

5-
核心使用 Rust edition 2021 编写,包名 innate,当前版本 0.1.16;release profile 启用 LTO、codegen-units=1、strip=true 以优化二进制体积。
5+
核心使用 Rust edition 2021 编写,包名 innate,当前版本 0.1.17;release profile 启用 LTO、codegen-units=1、strip=true 以优化二进制体积。
66

77
---
88

99
## SQLite 存储
1010

11-
使用 rusqlite 0.32(bundled feature)作为唯一持久化引擎,schema 版本 4.17;向量相似度由 Rust 代码直接计算,不依赖 sqlite-vec 扩展。
11+
使用 rusqlite 0.32(bundled feature)作为唯一持久化引擎,schema 版本 4.18;向量相似度由 Rust 代码直接计算,不依赖 sqlite-vec 扩展。
1212

1313
---
1414

AGENTS.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ Source is split into focused module directories (the old monolithic `kb.rs` / `s
110110
| `kb/appraise.rs` | `appraise` — 直觉/intuition critic; synchronous no-LLM footing check, reuses recall's fused score, returns valence/strength/tier/flagged_points (never an answer) |
111111
| `storage/{mod,chunks,traces,evolution,meta,raw}.rs` | rusqlite backend; schema init, BLOB-vector search, SQL helpers |
112112
| `embedding.rs` | `EmbeddingProvider` trait + `DummyEmbeddingProvider` (hash-based, for tests) |
113+
| `entities.rs` | `extract_entities` — deterministic (no-LLM, no-`regex`) high-signal entity extractor (error codes, flags, paths, code symbols) feeding the `chunk_entities` associative index for ACT-R spreading activation |
113114
| `llm.rs` | `HttpDistiller` (OpenAI-compatible + Anthropic, one type) + `LlmEmbeddingProvider`; HTTP retry transport |
114115
| `llm_trace.rs` | LLM/embedding call tracing — `post_json_retry` emits JSONL to `~/.innate/logs/llm_trace.log` (request/response previews, latency, retries, errors; never the API key). Read by `innate web` `/api/llm-traces` |
115116
| `refine.rs` | `Sanitizer`/`Refiner`/`Distiller` traits + `DefaultSanitizer`/`NoopSanitizer`/`NullRefiner`/`HeuristicDistiller` defaults + `ResilientDistiller` (wraps LLM distiller with deterministic fallback after retry-budget exhaustion, so capture never depends on the LLM) |
@@ -121,12 +122,12 @@ Source is split into focused module directories (the old monolithic `kb.rs` / `s
121122
| `install/{wizard,agents,skills,settings,path,ui,uninstall}.rs` | `innate install`/`uninstall` TUI — configures Claude/Codex/opencode MCP, skill, slash commands, Stop hook |
122123
| `backup/{mod,command}.rs` | Cloudflare R2 backup/restore/list/prune (S3-compatible + SigV4) |
123124
| `upgrade.rs` | `innate upgrade` — GitHub Releases self-update + SHA-256 verify + atomic swap |
124-
| `migrate.rs` | Schema migration chain 4.0 → 4.17, each step atomic |
125+
| `migrate.rs` | Schema migration chain 4.0 → 4.18, each step atomic |
125126
| `hook.rs` | `innate hook stop` — Claude Code Stop payload → session.log events |
126127
| `paths.rs` | Single source of truth for the `~/.innate` directory layout; `ensure_layout()` creates subdirs + migrates legacy flat files |
127128
| `utils.rs` | `utc_now_iso()`, `gen_uuid()`, `content_hash()`, `sanitize()`, cosine similarity |
128129
| `settings.rs` | `settings.json` parsing (LLM / Embedding / Daemon / Backup) |
129-
| `schema.sql` | Embedded schema (v4.17); `include_str!` at compile time |
130+
| `schema.sql` | Embedded schema (v4.18); `include_str!` at compile time |
130131

131132
### Filesystem layout (`~/.innate/`)
132133

@@ -164,10 +165,11 @@ No sqlite-vec dependency. Embeddings stored as raw `f32` BLOBs in `vec_content`
164165

165166
### Hybrid retrieval (混合检索, v4.16)
166167

167-
`recall` fuses **three** candidate channels, not just vectors: content-cosine, trigger-cosine, and a **lexical/BM25** channel (`chunks_fts`, a standalone FTS5 table kept in sync by triggers; `storage::search_lexical`). The lexical channel recovers exact-token matches (error codes, flags, symbol names) that embeddings blur. Fused score = `w_content·sim_content + w_trigger·sim_trigger + w_lexical·sim_lexical + w_confidence·conf + w_context·context_score + w_activation·activation` (+ pending/anti penalties). All weights are meta-tunable (`recall.w_*`).
168+
`recall` fuses **three** candidate channels, not just vectors: content-cosine, trigger-cosine, and a **lexical/BM25** channel (`chunks_fts`, a standalone FTS5 table kept in sync by triggers; `storage::search_lexical`). The lexical channel recovers exact-token matches (error codes, flags, symbol names) that embeddings blur. Fused score = `w_content·sim_content + w_trigger·sim_trigger + w_lexical·sim_lexical + w_spread·sim_spread + w_confidence·conf + w_context·context_score + w_activation·activation` (+ pending/anti penalties). All weights are meta-tunable (`recall.w_*`).
168169

169-
Two opt-in levers, both **off by default** so the no-LLM hot path is unchanged:
170+
Three opt-in levers, all **off by default** so the no-LLM hot path is unchanged:
170171
- `recall.embed_situation_signature` (meta flag) — fold the coarse situation signature into the embedded query (part c, granularity).
172+
- `recall.w_spread` (meta weight, default `0.0`) — **ACT-R spreading activation** (SAG-inspired associative recall, schema 4.18). Source activation flows from the **query's** entities and the entities of the top-`spread_seed_n` base candidates (2-hop), spreading `a_e / fan(e)` over the `chunk_entities` index (`storage::entity_links`/`entities_for_chunks`); the `1/fan` term is ACT-R's associative strength `S_ji = S − ln(fan_j)`, so promiscuous entities self-mute and entities with fan > `recall.spread_fan_cap` are dropped. Completes the activation model (base-level + associative). May pull in NEW candidates reachable only via a shared entity, which then flow through normal scoring. `expand_by_spreading` returns immediately when the weight is 0 — zero hot-path cost. Validate gains with a multi-hop `recall-eval` set before raising the weight.
171173
- `RecallParams.rerank` / CLI `--rerank` — offline LLM rerank of the shortlist via the injectable `Reranker` (default `NoopReranker`; `LlmReranker` wired by `open_kb` when an LLM is configured). **Never** used by hooks/MCP; non-fatal (falls back to fused order).
172174

173175
Measure recall quality on real data with `innate recall-eval <labels.jsonl> [--k N]` (reports P@1 / Recall@k / MRR / nDCG@k using the configured provider). Template: `scripts/recall_eval_template.jsonl`. This is distinct from `cargo test eval`, which validates the ranking math on dummy-embedding fixtures.
@@ -176,6 +178,8 @@ Measure recall quality on real data with `innate recall-eval <labels.jsonl> [--k
176178

177179
**Agent-source dimension (`agent` column, schema 4.17)**`chunks.agent` and `episodic_log.agent` record *which agent product* drove the call (`claude-code` / `codex` / `opencode` / …). This is **orthogonal** to the access channel in `usage_trace.source` / `episodic_log.event_source` (`mcp/cli/hook/daemon/...`): the channel says *which entry point*, `agent` says *which agent*. Source of truth is `utils::agent_source()`, which reads the `INNATE_AGENT` env var (injected by `innate install` into each agent's MCP config; `None`/NULL when unset — backward compatible, never enum-constrained). Distilled chunks **inherit** `agent` from their source `episodic_log` (not the process running `evolve`, which may be a daemon/cron); promoted sparks inherit from the spark. The Stop/hook capture channel is not yet env-tagged (records land with NULL agent) — follow-up.
178180

181+
**Associative entity index (`chunk_entities`, schema 4.18)** — populated by `Storage::insert_chunk` calling `entities::extract_entities` on the **same** write path that feeds `chunks_fts`, so every chunk writer (lifecycle/distill/curate) indexes entities with no extra wiring. Extraction is **deterministic** (no LLM, no `regex` dep) so it runs on the capture hot path and in migration backfill. Sparks are **excluded** (`origin='spark'` skipped) since they are recall-exempt. `entity_links` filters to recall-valid chunks (`state != 'archived' AND origin != 'spark'`), mirroring `search_lexical`; chunks are soft-archived (never hard-deleted), so no entity cleanup is needed on archive. Extraction deliberately ignores plain lowercase words — those are already covered by the lexical channel, and entities must stay discriminative so the ACT-R fan term works.
182+
179183
**Time functions**`utc_now_iso()` in `utils.rs` is the **only** time source. Format: `YYYY-MM-DDTHH:MM:SS.mmmZ` (fixed 3-digit ms). Never use system time directly. All SQL cutoff comparisons rely on lexicographic ordering of this format.
180184

181185
**`record()` distill_state transition**`open→new/discarded` judgment runs **only when `distill_state == 'open'`**. Second call must not downgrade a log already in `'new'` or `'screening'` state.

core/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "innate"
3-
version = "0.1.16"
3+
version = "0.1.17"
44
edition = "2021"
55
license = "MIT"
66
authors = ["vima-tech"]

core/src/entities.rs

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
//! Deterministic entity extraction for associative (spreading-activation) recall.
2+
//!
3+
//! SAG-inspired: a chunk's high-signal tokens (error codes, CLI flags, paths,
4+
//! code symbols) become index entities in `chunk_entities`. Two chunks that share
5+
//! an entity are associatively linked even when their prose is dissimilar, which
6+
//! lets `recall` spread activation across that link — the ACT-R associative term
7+
//! (`S_ji`) that base-level activation alone cannot supply.
8+
//!
9+
//! Deterministic on purpose — no LLM, no `regex` dependency (manual char
10+
//! scanning) — so it runs on the no-LLM capture hot path *and* in migration
11+
//! backfill. Mirrors the project constraint that capture never depends on the LLM
12+
//! (see `ResilientDistiller`).
13+
//!
14+
//! Deliberately ignores plain lowercase words: those are already recoverable via
15+
//! the lexical/BM25 channel, and entities must stay **discriminative** so the
16+
//! ACT-R fan term keeps promiscuous tokens from dominating the spread.
17+
18+
use std::collections::HashSet;
19+
20+
/// One extracted entity: the normalized join key plus a coarse type tag. The tag
21+
/// is diagnostic only — recall keys on `entity`, never on `etype`.
22+
#[derive(Debug, Clone, PartialEq, Eq)]
23+
pub struct ExtractedEntity {
24+
pub entity: String,
25+
pub etype: &'static str,
26+
}
27+
28+
/// Cap per chunk so a pathological blob can't bloat the index.
29+
const MAX_ENTITIES_PER_CHUNK: usize = 64;
30+
const MIN_ENTITY_LEN: usize = 2;
31+
32+
/// Punctuation trimmed from both ends of a token. Excludes `-` `/` `_` `:` so
33+
/// flags (`--release`), paths (`core/src/x.rs`), snake_case and `::` paths keep
34+
/// their structure; `.` is included so trailing sentence dots drop while internal
35+
/// dots (`file.rs`) survive (trim is end-anchored, not internal).
36+
const TRIM: &[char] = &[
37+
'"', '\'', '`', ',', ';', '.', '(', ')', '[', ']', '{', '}', '?', '!', '<', '>', '*', '|',
38+
'=', '@', '#', '%', '\\',
39+
];
40+
41+
/// Extract high-signal entities from a chunk's content and optional trigger desc.
42+
pub fn extract_entities(content: &str, trigger_desc: Option<&str>) -> Vec<ExtractedEntity> {
43+
let mut raw: Vec<(String, &'static str)> = Vec::new();
44+
for text in [Some(content), trigger_desc].into_iter().flatten() {
45+
collect_backtick_spans(text, &mut raw);
46+
for tok in text.split_whitespace() {
47+
if let Some(cl) = classify(tok) {
48+
raw.push(cl);
49+
}
50+
}
51+
}
52+
53+
let mut seen: HashSet<String> = HashSet::new();
54+
let mut out: Vec<ExtractedEntity> = Vec::new();
55+
for (entity, etype) in raw {
56+
if entity.len() >= MIN_ENTITY_LEN && seen.insert(entity.clone()) {
57+
out.push(ExtractedEntity { entity, etype });
58+
if out.len() >= MAX_ENTITIES_PER_CHUNK {
59+
break;
60+
}
61+
}
62+
}
63+
out
64+
}
65+
66+
/// Backtick-delimited spans are author-marked code, so their tokens clear a lower
67+
/// bar: any identifier-like piece becomes a `symbol` even if it would otherwise
68+
/// look like a plain word (e.g. `` `recall` ``).
69+
fn collect_backtick_spans(text: &str, raw: &mut Vec<(String, &'static str)>) {
70+
let mut in_span = false;
71+
let mut buf = String::new();
72+
for ch in text.chars() {
73+
if ch == '`' {
74+
if in_span {
75+
for piece in buf.split_whitespace() {
76+
if let Some(cl) = classify(piece) {
77+
raw.push(cl);
78+
} else if let Some(sym) = as_identifier(piece) {
79+
raw.push((sym, "symbol"));
80+
}
81+
}
82+
buf.clear();
83+
}
84+
in_span = !in_span;
85+
} else if in_span {
86+
buf.push(ch);
87+
}
88+
}
89+
}
90+
91+
/// Classify a whitespace-delimited token into a high-signal entity, or `None` for
92+
/// plain prose. Normalization lowercases for join stability — both the stored
93+
/// chunk text and the recall query run through this same function.
94+
fn classify(tok: &str) -> Option<(String, &'static str)> {
95+
// Flags keep their leading dashes (TRIM excludes '-'); detect before trimming.
96+
let pre = tok.trim_matches(TRIM);
97+
if pre.starts_with("--") && pre.len() >= 4 && pre[2..].chars().all(is_ident_char) {
98+
return Some((pre.to_lowercase(), "flag"));
99+
}
100+
let t = pre;
101+
if t.len() < MIN_ENTITY_LEN {
102+
return None;
103+
}
104+
105+
// Error code: short alpha prefix + >=3 digits, nothing else (E0277, GH1234).
106+
if let Some((alpha, digits)) = split_alpha_digits(t) {
107+
if (1..=4).contains(&alpha.len()) && digits.len() >= 3 {
108+
return Some((t.to_lowercase(), "error"));
109+
}
110+
}
111+
112+
// Rust-style path / namespaced symbol.
113+
if t.contains("::") && t.chars().all(is_ident_char) {
114+
return Some((t.to_lowercase(), "path"));
115+
}
116+
// Filesystem path: a slash plus a dotted file component.
117+
if t.contains('/') && t.contains('.') && t.chars().all(is_ident_char) {
118+
return Some((t.trim_end_matches('/').to_lowercase(), "path"));
119+
}
120+
121+
// snake_case / kebab identifier.
122+
if (t.contains('_') || t.contains('-')) && is_identifier_token(t) {
123+
return Some((t.to_lowercase(), "symbol"));
124+
}
125+
// CamelCase / mixedCase identifier.
126+
if is_camel_case(t) {
127+
return Some((t.to_lowercase(), "symbol"));
128+
}
129+
None
130+
}
131+
132+
/// Lower bar used inside backtick spans: accept any identifier-like token.
133+
fn as_identifier(tok: &str) -> Option<String> {
134+
let t = tok.trim_matches(TRIM);
135+
if t.len() >= MIN_ENTITY_LEN && is_identifier_token(t) {
136+
Some(t.to_lowercase())
137+
} else {
138+
None
139+
}
140+
}
141+
142+
fn is_ident_char(c: char) -> bool {
143+
c.is_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/')
144+
}
145+
146+
/// All chars are identifier chars and at least one is alphabetic (excludes bare
147+
/// numbers / version strings like `1.2.3`).
148+
fn is_identifier_token(t: &str) -> bool {
149+
t.chars().all(is_ident_char) && t.chars().any(|c| c.is_alphabetic())
150+
}
151+
152+
/// Split a token into a leading alphabetic run and a trailing all-digit run,
153+
/// rejecting anything with other characters in between.
154+
fn split_alpha_digits(t: &str) -> Option<(&str, &str)> {
155+
let split = t.find(|c: char| c.is_ascii_digit())?;
156+
let (alpha, digits) = t.split_at(split);
157+
if !alpha.is_empty()
158+
&& alpha.chars().all(|c| c.is_ascii_alphabetic())
159+
&& !digits.is_empty()
160+
&& digits.chars().all(|c| c.is_ascii_digit())
161+
{
162+
Some((alpha, digits))
163+
} else {
164+
None
165+
}
166+
}
167+
168+
/// Has an internal lower→upper transition (mixedCase / CamelCase) and is otherwise
169+
/// alphanumeric — captures `recallCandidates`, `KnowledgeBase`, `BM25`.
170+
fn is_camel_case(t: &str) -> bool {
171+
if !t.chars().all(|c| c.is_alphanumeric()) {
172+
return false;
173+
}
174+
let bytes: Vec<char> = t.chars().collect();
175+
bytes
176+
.windows(2)
177+
.any(|w| w[0].is_lowercase() && w[1].is_uppercase())
178+
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::*;
183+
184+
fn ents(content: &str) -> Vec<String> {
185+
extract_entities(content, None)
186+
.into_iter()
187+
.map(|e| e.entity)
188+
.collect()
189+
}
190+
191+
#[test]
192+
fn extracts_error_codes() {
193+
assert!(ents("hit error E0277 while building").contains(&"e0277".to_string()));
194+
// Bare numbers and version strings are not entities.
195+
assert!(!ents("bumped to 1.2.3 in 2026").contains(&"1.2.3".to_string()));
196+
}
197+
198+
#[test]
199+
fn extracts_flags_and_paths() {
200+
let e = ents("run cargo build --release and edit core/src/kb/recall.rs");
201+
assert!(e.contains(&"--release".to_string()));
202+
assert!(e.contains(&"core/src/kb/recall.rs".to_string()));
203+
}
204+
205+
#[test]
206+
fn extracts_symbols_not_plain_words() {
207+
let e = ents("call KnowledgeBase::recall via the snake_case helper get_deps");
208+
assert!(e.contains(&"knowledgebase::recall".to_string()));
209+
assert!(e.contains(&"snake_case".to_string()));
210+
assert!(e.contains(&"get_deps".to_string()));
211+
// plain prose is left to the lexical channel
212+
assert!(!e.contains(&"call".to_string()));
213+
assert!(!e.contains(&"via".to_string()));
214+
}
215+
216+
#[test]
217+
fn backtick_spans_lower_the_bar() {
218+
let e = ents("the `recall` method matters");
219+
assert!(e.contains(&"recall".to_string()));
220+
// outside backticks, the same lowercase word is ignored
221+
assert!(!ents("recall the method").contains(&"recall".to_string()));
222+
}
223+
224+
#[test]
225+
fn dedups_and_caps() {
226+
let e = extract_entities("E0277 E0277 E0277", None);
227+
assert_eq!(e.iter().filter(|x| x.entity == "e0277").count(), 1);
228+
}
229+
}

core/src/kb/inspection.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ impl KnowledgeBase {
347347
"recall.w_trigger": self.w_trigger,
348348
"recall.w_context": self.w_context,
349349
"recall.w_activation": self.w_activation,
350+
"recall.w_spread": self.w_spread,
350351
"recall.top_k_candidates": self.top_k_candidates,
351352
"curate.low_conf_threshold": self.low_conf_threshold,
352353
"curate.low_conf_idle_days": self.low_conf_idle_days,

0 commit comments

Comments
 (0)