feat(provenance): 阶段2 可溯源判据重定义 + 实测 lcs_len/prefix_hit60 入 jsonl (t_dc412532)

AC1 先量后判: jsonl 增记 lcs_len(最长公共子串) + prefix_hit60(前60字命中)。
AC2 判据重定义: 空原文(session 已压缩) -> unknown/source_not_available(不再误判
  false/not_in_source); 摘要低 LCS -> unknown/derived_low_lcs(正常改写不可证伪);
  命中 -> true(片段可溯源)。依据 24h 分布 true=1/false=8/unknown=193, 8条 false
  全来自 session-compress(摘要器)=结构性误判。
AC3 unknown 处置=放行(fail-open): 94.6% unknown 为 writer 侧盲区(无 source),
  非内容不可回证; 拒 unknown = 99.7% 拦 = 掐死织忆。
AC4 行为不变: 仍纯旁路观测, 不拒任何写入。
This commit is contained in:
xiaoxue
2026-09-30 19:36:09 +08:00
parent 631241c50a
commit 4fa2e11b78
+52 -30
View File
@@ -1,4 +1,4 @@
// 织忆 MemoryWeave — commit 写入的「引用可复算」旁路观测(阶段 1:只观测,不拦截)
// 织忆 MemoryWeave — commit 写入的「引用可复算」旁路观测(阶段 1→2:只观测,不拦截)
//
// 动机(t_3fbb6cbd):Akasha 的确定性校验器(sourceRange + quoteHash,fail-closed)
// 是唯一值得移植的东西。织忆现在的写入无法回证(09-25 实测卡点)。
@@ -9,12 +9,21 @@
// 开启时对每次 commit 做旁路观测(异步 goroutine,不阻塞、不改响应、不改写入路径),
// 结果追加到 ~/.hermes/logs/zhiyi-commit-provenance/YYYY-MM-DD.jsonl。
//
// 判定降级(AC2):
// 判定降级(AC2 重定义,t_dc412532 阶段2 设计输入):
// (a) metadata 有 source_uri + quote + quote_sha256 → 复算 sha256(quote) 比对;
// 不等 observable=false, reason=hash_mismatch
// (b) 无上述字段但 source=="session-compress" 且 session_id 存在 →
// 去 ~/.hermes/state.db(只读)取该 session 消息文本,查 content 前 60 字符
// 逐字命中(或最长公共子串 >=60)→ 命中 observable=true,否则 false reason=not_in_source
// 去 ~/.hermes/state.db(只读)取该 session 消息文本做回证。
// 观察期实测(n=205, 24h):true=1 / false=8 / unknown=193;全部 8 条 false
// 均来自 session-compress —— 判据「前 60 字逐字命中 / LCS≥60」对「摘要/派生内容」
// 结构性必 false(摘要不会逐字出现在原文里)。AC2 修正:
// * 原文不可得(session 已压缩/清除)→ observable=unknown, reason=source_not_available
// (空原文 = 无法证伪,不是证伪失败 —— 旧逻辑误标 false/not_in_source)
// * 前 60 字逐字命中 或 LCS≥60 → observable=true(引用片段可溯源)
// * 其余(摘要低 LCS,属正常改写)→ observable=unknown, reason=derived_low_lcs
// (对派生内容无法证伪 —— 摘要重述≠不可溯源)
// 同时把实测数值 lcs_len / prefix_hit60 写进 jsonl(AC1 先量后判),
// 供阶段3 转硬闸时按分布定档。
// (c) 其他 source → observable=unknown, reason=no_derivation(不要瞎猜成 false)
package routes
@@ -46,7 +55,9 @@ const stateDBPath = "/home/muc/.hermes/state.db"
// provenanceLogDir 观测结果落盘目录
const provenanceLogDir = "/home/muc/.hermes/logs/zhiyi-commit-provenance"
// minCommonRun 判定「可复算」的最长公共子串下界(字符)
// minCommonRun 判定「可溯源」的最长公共子串下界(字符)。
// 依据(t_dc412532 阶段2 实测分布):观察期 session-compress 摘要 content_len 均值 ~450,
// 要求 LCS≥60 取「摘要中至少出现一段可对回原文的片段」,过低会把无关噪声当溯源。
const minCommonRun = 60
// observeCommitProvenance 旁路观测一次 commit(异步调用,永不阻塞写路径)
@@ -67,6 +78,8 @@ func observeCommitProvenance(agentID, content string, metadata map[string]any) {
observable := "unknown"
reason := "no_derivation"
lcsLen := 0
prefixHit60 := false
// (a) 显式 quote + hash
if metadata != nil {
@@ -77,22 +90,24 @@ func observeCommitProvenance(agentID, content string, metadata map[string]any) {
observable, reason = decideQuoteHash(quote, qh)
} else if source == "session-compress" && sessionID != "" {
// (b) session-compress:去 state.db 只读回证
observable, reason = observeViaSession(content, sessionID)
observable, reason, lcsLen, prefixHit60 = observeViaSession(content, sessionID)
}
} else if source == "session-compress" && sessionID != "" {
observable, reason = observeViaSession(content, sessionID)
observable, reason, lcsLen, prefixHit60 = observeViaSession(content, sessionID)
}
// (c) 其余保持 unknown / no_derivation
line := map[string]interface{}{
"ts": time.Now().Format(time.RFC3339),
"agent_id": agentID,
"source": source,
"session_id": sessionID,
"observable": observable,
"reason": reason,
"content_len": len(content),
"ts": time.Now().Format(time.RFC3339),
"agent_id": agentID,
"source": source,
"session_id": sessionID,
"observable": observable,
"reason": reason,
"content_len": len(content),
"lcs_len": lcsLen, // AC1: content 与原文的最长公共子串长度(实测数值)
"prefix_hit60": prefixHit60, // AC1: 前 60 字是否逐字命中原文
}
appendProvenanceLog(line)
}
@@ -106,18 +121,21 @@ func decideQuoteHash(quote, quoteSHA256 string) (string, string) {
return "false", "hash_mismatch"
}
// observeViaSession 去 state.db(只读)取 session 消息文本,回证 content 可复算。
func observeViaSession(content, sessionID string) (string, string) {
// observeViaSession 去 state.db(只读)取 session 消息文本,回证 content 可溯源。
// 返回 (observable, reason, lcs_len, prefix_hit60)。
// AC2 语义:对派生/摘要内容不做逐字强判 —— 原文不可得→unknown(无法证伪),
// 命中→true(片段可溯源),低 LCS→unknown(摘要改写正常,不可证伪)。
func observeViaSession(content, sessionID string) (string, string, int, bool) {
db, err := sql.Open("sqlite", "file:"+stateDBPath+"?_pragma=query_only(1)&_pragma=busy_timeout(3000)")
if err != nil {
return "unknown", "state_db_open_failed: " + err.Error()
return "unknown", "state_db_open_failed: " + err.Error(), 0, false
}
defer db.Close()
rows, err := db.Query(
"SELECT content FROM messages WHERE session_id=? AND role IN ('user','assistant') ORDER BY id",
sessionID)
if err != nil {
return "unknown", "state_db_query_failed: " + err.Error()
return "unknown", "state_db_query_failed: " + err.Error(), 0, false
}
defer rows.Close()
@@ -132,27 +150,30 @@ func observeViaSession(content, sessionID string) (string, string) {
}
srcText := sb.String()
if srcText == "" {
return "false", "not_in_source"
// 原文不可得:无法证伪,不是证伪失败(旧逻辑误标 false/not_in_source)
return "unknown", "source_not_available", 0, false
}
if provenanceMatch(content, srcText) {
return "true", ""
match, lcsLen, prefixHit60 := provenanceMeasure(content, srcText)
if match {
return "true", "", lcsLen, prefixHit60
}
return "false", "not_in_source"
// 摘要/派生内容低 LCS 属正常改写,无法证伪 —— 不算 false
return "unknown", "derived_low_lcs", lcsLen, prefixHit60
}
// provenanceMatch 判定 content 相对 srcText 可复算:
// content 前 60 字符在 srcText 中逐字命中,或 最长公共子串 >=60。
func provenanceMatch(content, srcText string) bool {
// provenanceMeasure 计算 content 相对 srcText 的可溯源实测指标:
// prefixHit60 = content 前 60 字在 srcText 中逐字命中
// lcsLen = content 与 srcText 的最长公共子串长度
// match = prefixHit60 || lcsLen >= minCommonRun
func provenanceMeasure(content, srcText string) (match bool, lcsLen int, prefixHit60 bool) {
if content == "" || srcText == "" {
return false
return false, 0, false
}
first := content
if len(first) > minCommonRun {
first = first[:minCommonRun]
}
if strings.Contains(srcText, first) {
return true
}
prefixHit60 = strings.Contains(srcText, first)
// LCS 兜底(受限输入防打爆)
c := content
if len(c) > 2000 {
@@ -162,7 +183,8 @@ func provenanceMatch(content, srcText string) bool {
if len(s) > 20000 {
s = s[:20000]
}
return lcsLength(c, s) >= minCommonRun
lcsLen = lcsLength(c, s)
return prefixHit60 || lcsLen >= minCommonRun, lcsLen, prefixHit60
}
// lcsLength 两个字符串的最长公共子串长度(O(n*m) 动态规划,取较短者做列)。