liquid-loop 1.8.2__tar.gz → 1.8.4__tar.gz

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.
Files changed (49) hide show
  1. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/PKG-INFO +1 -1
  2. liquid_loop-1.8.4/liquid_loop/__init__.py +17 -0
  3. liquid_loop-1.8.4/liquid_loop/context_compress.py +214 -0
  4. liquid_loop-1.8.4/liquid_loop/procedural_memory.py +361 -0
  5. liquid_loop-1.8.4/liquid_loop/recall_filter.py +126 -0
  6. liquid_loop-1.8.4/liquid_loop/self_eval.py +164 -0
  7. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/workspace.py +11 -2
  8. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop.egg-info/PKG-INFO +1 -1
  9. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop.egg-info/SOURCES.txt +8 -0
  10. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/pyproject.toml +1 -1
  11. liquid_loop-1.8.4/tests/test_context_compress.py +134 -0
  12. liquid_loop-1.8.4/tests/test_procedural_memory.py +208 -0
  13. liquid_loop-1.8.4/tests/test_recall_filter.py +79 -0
  14. liquid_loop-1.8.4/tests/test_self_eval.py +77 -0
  15. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_semantica_borrow.py +23 -0
  16. liquid_loop-1.8.2/liquid_loop/__init__.py +0 -11
  17. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/LICENSE +0 -0
  18. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/README.md +0 -0
  19. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/__main__.py +0 -0
  20. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/cli.py +0 -0
  21. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/cognitive_budget.py +0 -0
  22. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/entropy.py +0 -0
  23. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/liquid_reweight.py +0 -0
  24. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/rar.py +0 -0
  25. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/selfspin.py +0 -0
  26. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop/storage.py +0 -0
  27. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop.egg-info/dependency_links.txt +0 -0
  28. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop.egg-info/entry_points.txt +0 -0
  29. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop.egg-info/requires.txt +0 -0
  30. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop.egg-info/top_level.txt +0 -0
  31. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop_exp/liquid_persist_ab.py +0 -0
  32. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/liquid_loop_exp/liquid_recall_ab.py +0 -0
  33. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/setup.cfg +0 -0
  34. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_attention_gain.py +0 -0
  35. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_auth_guard.py +0 -0
  36. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_cli_version.py +0 -0
  37. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_consensus_expansion.py +0 -0
  38. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_entropy.py +0 -0
  39. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_layer1_persistence.py +0 -0
  40. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_lifecycle.py +0 -0
  41. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_liquid_reweight.py +0 -0
  42. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_nucleate_dual_track.py +0 -0
  43. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_peek_seal.py +0 -0
  44. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_rar.py +0 -0
  45. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_replay_pressure.py +0 -0
  46. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_seal_persistence.py +0 -0
  47. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_self_evolve.py +0 -0
  48. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_selfspin.py +0 -0
  49. {liquid_loop-1.8.2 → liquid_loop-1.8.4}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: liquid-loop
3
- Version: 1.8.2
3
+ Version: 1.8.4
4
4
  Summary: Self-Organizing Cognitive Memory for AI Agents — Liquid Loop theory implementation
5
5
  Author: fishbook0001
6
6
  Maintainer: fishbook0001
@@ -0,0 +1,17 @@
1
+ """Liquid Loop — Workspace Cognitive Runtime v1.8.4 (禁向量·活态液态神经网络:成核门槛/反证轨/老化回收/原理优先成核/因果演化/软取代supersede+治理查询state_at/impact/duplicates+程序性记忆层procedural_memory)"""
2
+ __version__ = "1.8.4"
3
+
4
+ from .workspace import (
5
+ WorkspaceState, Anchor, Evidence, Memory, Conflict,
6
+ AuditChain, CPERegularizer, SelfRefineEngine,
7
+ )
8
+ from .storage import load, save, locked_state
9
+ from .entropy import calculate, calculate_detail, calculate as calculate_entropy
10
+ from .selfspin import LiquidSelfSpin
11
+ from .liquid_reweight import LiquidReweight
12
+ from .context_compress import ExtractiveCondenser, compress_context, CondenseReport, structured_note
13
+ from .self_eval import consensus_self_check, diverg_beta, majority_vote, u_opsd_step
14
+ from .recall_filter import recall_content_filter, content_aware_filter, cosine_dict
15
+ from .procedural_memory import (
16
+ ProceduralMemory, ProceduralRegistry, procmem_recall, procmem_admit,
17
+ )
@@ -0,0 +1,214 @@
1
+ """长会话/长上下文提取式压缩(蒸馏自 Octomind condense.rs,落地液环)
2
+
3
+ 把外部 runtime 的「提取式上下文压缩」蒸馏为液环一等公民组件:
4
+ - 超阈值触发 → 选行保留(非生成式摘要)
5
+ - 单条 < MIN_CANDIDATE_TOKENS 绝不触碰
6
+ - 压后 ≥ 压前则保留原文(no-gain 保护)
7
+ - 任何异常保留原文(fail-open)
8
+
9
+ 契合液环哲学:零向量、确定性、零丢失。
10
+ 压缩仅裁剪噪声行,原文语义结构(决策/错误/文件路径/代码/结论)保留;
11
+ 底层 storage 完整存档,「蒸馏」与「归档」互不干扰。
12
+
13
+ 触发阈值:环境变量 LIQUID_CONTEXT_COMPRESS_TOKENS(默认 0 = 不压缩),
14
+ 与 LIQUID_EVIDENCE_BUDGET 运维风格一致。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from typing import Optional
22
+
23
+
24
+ def estimate_tokens(text: str) -> int:
25
+ """字符数 / 4 启发式(对预算触发判断足够精确,零依赖 tiktoken)。"""
26
+ if not text:
27
+ return 0
28
+ return max(1, len(text) // 4)
29
+
30
+
31
+ # 高价值行信号:保留含错误/决策/文件路径/代码定义/符号的行
32
+ _HIGH_VALUE = re.compile(
33
+ r"(?i)("
34
+ r"\b(error|failed|exception|traceback|panic)\b" # 错误/异常
35
+ r"|\b(decision|changed|fixed|result|todo|fix|note)\b" # 决策/结论
36
+ r"|[\w./\-]+\.(py|rs|ts|js|go|toml|json|md|yaml|yml)" # 文件路径
37
+ r"|^\s*(def|fn|func|class|impl|pub|import|export)\b" # 代码定义
38
+ r"|=>|==|!=|::|->" # 代码符号
39
+ r")"
40
+ )
41
+ # 噪声行信号:空/纯符号行、闲聊开场
42
+ _NOISE = re.compile(
43
+ r"(?i)^[\s>*\-–·•]*$"
44
+ r"|^(thinking|let me|i'll|sure|okay|great|here's)\b"
45
+ )
46
+
47
+
48
+ @dataclass
49
+ class CondenseReport:
50
+ """压缩运行报告(对齐原版统计字段)。"""
51
+
52
+ triggered: bool = False
53
+ candidates: int = 0
54
+ condensed: int = 0
55
+ untouched: int = 0
56
+ saved_tokens: int = 0
57
+ notes: list = field(default_factory=list)
58
+
59
+
60
+ class ExtractiveCondenser:
61
+ """超阈值触发 → 选行保留(非生成式摘要)→ fail-open 不崩。
62
+
63
+ 与原版 condense_round 不变量对齐:
64
+ - 整轮 token 总和 > threshold 才触发(防小结果免费注入大轮)
65
+ - 单条 < MIN_CANDIDATE_TOKENS 绝不触碰
66
+ - 压后 ≥ 压前则保留原文 (no-gain)
67
+ - 任何异常返回原样 (fail-open)
68
+ """
69
+
70
+ MIN_CANDIDATE_TOKENS = 512
71
+ KEEP_RATIO = 0.5 # 候选结果最多保留的行比例上限
72
+
73
+ def __init__(self, tokens_threshold: int = 0):
74
+ # 0 = 不压缩(缺省关闭,与 LIQUID_EVIDENCE_BUDGET 风格一致)
75
+ self.tokens_threshold = tokens_threshold
76
+
77
+ def _score_line(self, line: str) -> float:
78
+ """启发式重要性打分(替代原版 cheap-LLM 行选择,零 LLM 依赖)。"""
79
+ s = 0.0
80
+ if _HIGH_VALUE.search(line):
81
+ s += 3.0
82
+ if _NOISE.search(line):
83
+ s -= 2.0
84
+ stripped = line.strip()
85
+ if len(stripped) >= 20:
86
+ s += 1.0
87
+ # 近似重复行惩罚
88
+ if len(stripped) > 40 and stripped.count(stripped[:10]) > 2:
89
+ s -= 1.5
90
+ return s
91
+
92
+ def condense_round(self, results: list) -> tuple:
93
+ """results: list of dict {"tool": str, "content": str}
94
+
95
+ 返回 (压缩后 results, CondenseReport)。fail-open 时 triggered=False。
96
+ """
97
+ rep = CondenseReport()
98
+ try:
99
+ sizes = [estimate_tokens(r.get("content", "")) for r in results]
100
+ if sum(sizes) <= self.tokens_threshold:
101
+ return results, rep # 未触发
102
+ rep.triggered = True
103
+
104
+ candidates = [i for i, t in enumerate(sizes)
105
+ if t >= self.MIN_CANDIDATE_TOKENS]
106
+ rep.candidates = len(candidates)
107
+ if not candidates:
108
+ return results, rep
109
+
110
+ out = list(results)
111
+ for idx in candidates:
112
+ r = results[idx]
113
+ original = r.get("content", "")
114
+ before = estimate_tokens(original)
115
+ lines = original.splitlines()
116
+ if len(lines) <= 2:
117
+ rep.untouched += 1
118
+ continue
119
+ scored = [(self._score_line(ln), i, ln) for i, ln in enumerate(lines)]
120
+ scored.sort(key=lambda x: x[0], reverse=True)
121
+ keep_n = max(2, int(len(lines) * self.KEEP_RATIO))
122
+ keep_idx = sorted(i for _, i, _ in scored[:keep_n])
123
+ new_content = "\n".join(lines[i] for i in keep_idx)
124
+ after = estimate_tokens(new_content)
125
+ if after >= before: # no-gain 保护
126
+ rep.untouched += 1
127
+ continue
128
+ out[idx] = {"tool": r.get("tool", ""), "content": new_content}
129
+ rep.condensed += 1
130
+ rep.saved_tokens += (before - after)
131
+ if rep.condensed:
132
+ rep.notes.append("📎 CONDENSED by supervisor")
133
+ return out, rep
134
+ except Exception as e:
135
+ rep.notes.append(f"fail-open: {e}")
136
+ return results, rep
137
+
138
+
139
+ def compress_context(
140
+ texts: list,
141
+ threshold: Optional[int] = None,
142
+ ) -> tuple:
143
+ """液环便捷入口:把多条证据/上下文作为一轮压缩。
144
+
145
+ texts: 待压缩文本列表(如 recall 返回的多条证据 content)
146
+ threshold: 触发阈值 token;None 时读 LIQUID_CONTEXT_COMPRESS_TOKENS(默认 0=不压)
147
+ 返回 (压缩后文本列表, CondenseReport)
148
+ """
149
+ if threshold is None:
150
+ threshold = int(os.environ.get("LIQUID_CONTEXT_COMPRESS_TOKENS", "0"))
151
+ if threshold <= 0:
152
+ return list(texts), CondenseReport() # 未启用
153
+ results = [{"tool": "", "content": t} for t in texts]
154
+ out, rep = ExtractiveCondenser(threshold).condense_round(results)
155
+ return [r["content"] for r in out], rep
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # RE-TRAC 同构:结构化三组分笔记(提取式分桶,非生成式)
160
+ # 把压缩后的证据/上下文按语义分桶为 {answer, evidence, open},
161
+ # 对应 RE-TRAC 的「当前最优答案 + 证据库 + 不确定项/待探索」。
162
+ # 零 LLM 依赖、fail-open,契合液环「提取式、确定性、零丢失」哲学。
163
+ # ---------------------------------------------------------------------------
164
+ _BUCKET_ANSWER = re.compile(
165
+ r"(?i)("
166
+ r"\b(result|fixed|changed|decision|conclusion|answer|resolved|done)\b"
167
+ r"|结论|已修复|决定|已解决"
168
+ r")"
169
+ )
170
+ _BUCKET_OPEN = re.compile(
171
+ r"(?i)("
172
+ r"\b(failed|exception|error|uncertain|unknown|pending|todo|wip)\b"
173
+ r"|待探索|待定|不确定|失败|待办|未解决"
174
+ r")"
175
+ )
176
+ _BUCKET_EVIDENCE = re.compile(
177
+ r"(?i)("
178
+ r"[\w./\-]+\.(py|rs|ts|js|go|toml|json|md|yaml|yml)" # 文件路径
179
+ r"|=>|==|!=|::|->" # 代码符号
180
+ r"|\b(path|evidence|data|source|依据|来源|数据)\b"
181
+ r")"
182
+ )
183
+
184
+
185
+ def structured_note(
186
+ texts: list,
187
+ threshold: Optional[int] = None,
188
+ ) -> dict:
189
+ """RE-TRAC 同构:把多条证据/上下文压成结构化三组分笔记。
190
+
191
+ 返回 {answer, evidence, open} 三个文本列表。分桶为提取式
192
+ (按行语义正则归类),不生成新内容,fail-open(异常返回空桶)。
193
+
194
+ - answer : 当前最优结论 / 已修复 / 决策
195
+ - evidence : 路径 / 符号 / 数据 / 来源等支撑性内容(保底桶,不丢行)
196
+ - open : 失败 / 异常 / 不确定 / 待探索项
197
+ """
198
+ try:
199
+ compressed, _ = compress_context(texts, threshold)
200
+ buckets = {"answer": [], "evidence": [], "open": []}
201
+ for t in compressed:
202
+ for line in t.splitlines():
203
+ s = line.strip()
204
+ if not s:
205
+ continue
206
+ if _BUCKET_OPEN.search(s):
207
+ buckets["open"].append(s)
208
+ elif _BUCKET_ANSWER.search(s):
209
+ buckets["answer"].append(s)
210
+ else:
211
+ buckets["evidence"].append(s) # 保底不丢
212
+ return buckets
213
+ except Exception:
214
+ return {"answer": [], "evidence": [], "open": []}
@@ -0,0 +1,361 @@
1
+ """程序性记忆层(distill_registry 实现 · D3 + 几轮军师调研蒸馏整合)
2
+
3
+ 蒸馏这几轮调研的「必要内容」,落地为液环一等公民组件:
4
+
5
+ - PlugMem(#124 / #170 第二信源): procedural(行动处方)记忆一等公民,按任务召回
6
+ → 本模块把「技能/配方」作为独立记忆类型,与声明式 Anchor/Evidence/Memory 正交
7
+ - Skill1(#169): 单一任务信号拆三份 → 此处落地为「选择/利用/蒸馏」三类信用:
8
+ 选择信用 = 任务标签匹配度;利用信用 = 复用成功率;蒸馏信用 = 改进增量
9
+ - EvoC2F(#172): 验证门控技能进化(功能测试+契约验证+回归评估)+分阶段部署
10
+ (shadow→canary→active) + 不可逆操作交人工(↔ ops_gate 不可逆确认)
11
+ - RE-TRAC(#165, 已落 structured_note): 技能笔记用 {answer, evidence, open} 三组分
12
+
13
+ 守液环铁律:
14
+ - 禁向量:任务路由用结构化 tag 精确/子串匹配,绝不 cosine / embedding
15
+ - 提取式/确定性:准入判定为确定性规则,零 LLM 依赖
16
+ - fail-open:任何异常返回安全默认(空列表/降级状态),不阻断主流程
17
+ - 零丢失:被拒/降级的技能仍保留(gate_status=rejected),可审计、可解冻
18
+
19
+ 持久化:sidecar `.liquid/procedural.json`,独立 fcntl 锁,不触碰 WorkspaceState 序列化
20
+ (与 storage.py 同风格,但独立文件避免扩大 WorkspaceState 改动面)。
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import fcntl
25
+ import json
26
+ import os
27
+ import re
28
+ from dataclasses import dataclass, field, fields
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ from .context_compress import structured_note
33
+ from .workspace import now
34
+
35
+
36
+ # ── 阈值(环境变量可覆写,缺省保守)─────────────────────────────────────
37
+ def _env_float(name: str, default: float) -> float:
38
+ try:
39
+ v = float(os.environ.get(name, default))
40
+ return v
41
+ except (TypeError, ValueError):
42
+ return default
43
+
44
+
45
+ REGRESSION_EPS = _env_float("LIQUID_PROCMEM_REGRESSION_EPS", 0.6) # 回归门:成功率≥此值才晋级
46
+ MIN_USES_SHADOW = max(1, int(_env_float("LIQUID_PROCMEM_MIN_USES", 3))) # 出 shadow 最少使用次数(≥1)
47
+ MIN_USES_ACTIVE = MIN_USES_SHADOW * 2 # 出 canary 进 active 所需使用次数
48
+
49
+ _ACTION_VERB = re.compile(
50
+ r"(?i)\b(run|exec|invoke|call|use|apply|send|start|build|deploy|install|"
51
+ r"query|fetch|write|read|patch|create|delete|test|verify|extract)\b"
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class ProceduralMemory:
57
+ """一条程序性记忆(行动处方)。
58
+
59
+ 与声明式 Memory(结晶自≥2一致证据) 正交:这是「怎么做」而非「是什么」。
60
+ """
61
+
62
+ skill_id: str = ""
63
+ task_tags: list = field(default_factory=list) # 结构化任务标签(非向量);选择信用匹配键
64
+ invocation: str = "" # 行动处方(how-to/命令/配方)
65
+ evidence: str = "" # 成功证据/来源
66
+ note: dict = field(default_factory=lambda: {"answer": [], "evidence": [], "open": []}) # RE-TRAC 三组分
67
+ gate_status: str = "pending" # pending→shadow→canary→active | rejected
68
+ admit_score: float = 0.0 # EvoC2F 三门前综合分(0~1)
69
+ use_count: int = 0
70
+ success_count: int = 0
71
+ contract: dict = field(default_factory=dict) # {inputs, outputs} 契约(门2 校验依据)
72
+ irreversible: bool = False # 不可逆操作→交人工(↔ ops_gate)
73
+ requires_human: bool = False # 不可逆技能晋级到 canary 后需人工放行
74
+ created_at: str = field(default_factory=now)
75
+ last_used_at: str = ""
76
+
77
+ # ── 派生指标 ──
78
+ @property
79
+ def success_rate(self) -> float:
80
+ if self.use_count == 0:
81
+ return 0.0
82
+ return self.success_count / self.use_count
83
+
84
+ @property
85
+ def usable(self) -> bool:
86
+ """是否可被任务召回(降级/待审不可召回)。"""
87
+ return self.gate_status in ("active", "canary")
88
+
89
+ def as_dict(self) -> dict:
90
+ return {
91
+ "skill_id": self.skill_id,
92
+ "task_tags": list(self.task_tags),
93
+ "invocation": self.invocation,
94
+ "evidence": self.evidence,
95
+ "note": self.note,
96
+ "gate_status": self.gate_status,
97
+ "admit_score": round(self.admit_score, 3),
98
+ "use_count": self.use_count,
99
+ "success_count": self.success_count,
100
+ "success_rate": round(self.success_rate, 3),
101
+ "contract": self.contract,
102
+ "irreversible": self.irreversible,
103
+ "requires_human": self.requires_human,
104
+ "created_at": self.created_at,
105
+ "last_used_at": self.last_used_at,
106
+ }
107
+
108
+
109
+ class ProceduralRegistry:
110
+ """程序性记忆注册表:验证门控准入 + 分阶段部署 + 任务路由召回。
111
+
112
+ fail-open:load/save/admit/recall 任意异常均返回安全默认,不抛出。
113
+ 独立 sidecar 持久化,不依赖 WorkspaceState。
114
+ """
115
+
116
+ STORE_NAME = "procedural.json"
117
+
118
+ def __init__(self, workspace_root: Path):
119
+ self.root = Path(workspace_root)
120
+ self.path = self.root / ".liquid" / self.STORE_NAME
121
+ self._items: dict = {}
122
+ self._load()
123
+
124
+ # ── 持久化(fcntl 排他锁,与 storage.py 同风格)─────────────────────
125
+ def _load(self) -> None:
126
+ try:
127
+ if self.path.exists():
128
+ with open(self.path, "r", encoding="utf-8") as f:
129
+ data = json.load(f)
130
+ _fields = {f.name for f in fields(ProceduralMemory)}
131
+ self._items = {
132
+ sid: ProceduralMemory(**{k: v for k, v in it.items() if k in _fields})
133
+ for sid, it in data.get("skills", {}).items()
134
+ }
135
+ except Exception:
136
+ self._items = {}
137
+
138
+ def _save(self) -> bool:
139
+ """原子写 sidecar;返回是否成功(失败不阻断,但调用方可感知)。"""
140
+ try:
141
+ self.path.parent.mkdir(parents=True, exist_ok=True)
142
+ data = {"skills": {sid: it.as_dict() for sid, it in self._items.items()}}
143
+ tmp = self.path.with_suffix(".tmp")
144
+ with open(tmp, "w", encoding="utf-8") as f:
145
+ json.dump(data, f, indent=2, ensure_ascii=False)
146
+ f.flush()
147
+ os.fsync(f.fileno())
148
+ os.replace(tmp, self.path)
149
+ return True
150
+ except Exception:
151
+ return False # fail-open:持久化失败不阻断,但结果带 warning
152
+
153
+ def _with_lock(self, fn):
154
+ """对 sidecar 加排他锁后执行 fn,保证 load→modify→save 原子。
155
+
156
+ 非阻塞锁 + fail-open:锁被占用(竞争/陈旧进程)时立即返回错误,
157
+ 绝不阻塞调用方(否则会拖垮整个 HTTP 端点)。这符合液环 fail-open 铁律。
158
+ """
159
+ try:
160
+ self.path.parent.mkdir(parents=True, exist_ok=True)
161
+ lk = open(self.path.with_suffix(".lock"), "w")
162
+ try:
163
+ fcntl.flock(lk, fcntl.LOCK_EX | fcntl.LOCK_NB)
164
+ except BlockingIOError:
165
+ return {"ok": False, "error": "lock busy (fail-open, 重试)"}
166
+ try:
167
+ self._load()
168
+ out = fn()
169
+ saved = self._save()
170
+ if isinstance(out, dict) and out.get("ok") and not saved:
171
+ out = {**out, "warning": "持久化失败(内存已更新,未落盘)"}
172
+ return out
173
+ finally:
174
+ fcntl.flock(lk, fcntl.LOCK_UN)
175
+ lk.close()
176
+ except Exception as e:
177
+ return {"ok": False, "error": f"fail-open: {e}"}
178
+
179
+ # ── EvoC2F 三关准入 ──────────────────────────────────────────────
180
+ def _gate_functional(self, invocation: str) -> tuple:
181
+ """门1 功能测试(本地代理):行动处方非空且含可行动词/足够长度。"""
182
+ if not invocation or len(invocation.strip()) < 8:
183
+ return 0.0, "invocation 过短或为空"
184
+ has_verb = bool(_ACTION_VERB.search(invocation))
185
+ return (0.7 if has_verb else 0.4), ("含可行动词" if has_verb else "缺可行动词(弱)")
186
+
187
+ def _gate_contract(self, evidence: str, contract) -> tuple:
188
+ """门2 契约验证(本地代理):若提供契约,校验证据涵盖声明输出键。"""
189
+ if not isinstance(contract, dict) or not contract:
190
+ return 1.0, "无契约声明→直接通过"
191
+ outs = contract.get("outputs", [])
192
+ if not outs:
193
+ return 1.0, "契约无输出声明"
194
+ if not evidence:
195
+ return 0.0, "有契约但缺证据可校验"
196
+ hit = sum(1 for o in outs if o and o in evidence)
197
+ frac = hit / len(outs)
198
+ ok = frac >= 0.5
199
+ return (frac, f"证据涵盖 {hit}/{len(outs)} 输出键" + ("" if ok else "→不足"))
200
+
201
+ def _gate_regression(self, use_count: int, success_rate: float) -> tuple:
202
+ """门3 回归评估(本地代理):无使用数据前不可定级,置 shadow。"""
203
+ if use_count == 0:
204
+ return 0.0, "尚无使用数据→置 shadow 观察"
205
+ ok = success_rate >= REGRESSION_EPS
206
+ return (success_rate, f"成功率 {success_rate:.2f} " + ("≥阈值" if ok else "<阈值"))
207
+
208
+ def admit(self, skill_id: str, task_tags: list, invocation: str,
209
+ evidence: str = "", contract: Optional[dict] = None,
210
+ irreversible: bool = False) -> dict:
211
+ """准入一条程序性记忆,跑 EvoC2F 三关 + 分阶段部署。
212
+
213
+ 返回 {ok, skill_id, gates, gate_status, admit_score}。fail-open。
214
+ 已 active/canary 的 skill 重准入时**保留晋级进度与复用历史**(仅更新内容字段),
215
+ 不把已验证技能降回 shadow;shadow/rejected 重准入视为新尝试。
216
+ """
217
+ def _do():
218
+ if contract is not None and not isinstance(contract, dict):
219
+ return {"ok": False, "error": "contract 必须是 dict 对象"}
220
+ existing = self._items.get(skill_id)
221
+ f_s, f_msg = self._gate_functional(invocation)
222
+ c_s, c_msg = self._gate_contract(evidence, contract or {})
223
+ r_s, r_msg = self._gate_regression(0, 0.0)
224
+ # 三关加权:功能 0.4 / 契约 0.3 / 回归(初始 0) 0.3 → 初始分由功能+契约决定
225
+ admit_score = round(0.4 * f_s + 0.3 * c_s + 0.3 * 0.0, 3)
226
+ # 功能不过 → 直接 rejected(脏技能污染防护,↔ Voyager 脏技能)
227
+ status = "rejected" if f_s < 0.4 else "shadow"
228
+ # 已验证技能(active/canary)重准入 → 保留晋级进度与历史
229
+ preserved = bool(existing and existing.gate_status in ("active", "canary"))
230
+ if preserved:
231
+ status = existing.gate_status
232
+ note = structured_note([evidence]) if evidence else {"answer": [], "evidence": [], "open": []}
233
+ pm = ProceduralMemory(
234
+ skill_id=skill_id,
235
+ task_tags=list(task_tags or []),
236
+ invocation=invocation,
237
+ evidence=evidence,
238
+ note=note,
239
+ gate_status=status,
240
+ admit_score=admit_score,
241
+ use_count=existing.use_count if preserved else 0,
242
+ success_count=existing.success_count if preserved else 0,
243
+ contract=dict(contract or {}),
244
+ irreversible=bool(irreversible),
245
+ requires_human=bool(irreversible) or bool(existing and existing.requires_human),
246
+ created_at=existing.created_at if existing else now(),
247
+ last_used_at=existing.last_used_at if preserved else "",
248
+ )
249
+ self._items[skill_id] = pm
250
+ return {
251
+ "ok": True,
252
+ "skill_id": skill_id,
253
+ "gates": {
254
+ "functional": {"score": round(f_s, 3), "msg": f_msg},
255
+ "contract": {"score": round(c_s, 3), "msg": c_msg},
256
+ "regression": {"score": round(r_s, 3), "msg": r_msg},
257
+ },
258
+ "gate_status": status,
259
+ "admit_score": admit_score,
260
+ }
261
+ return self._with_lock(_do)
262
+
263
+ # ── 晋级/回退(EvoC2F 分阶段部署 + 回归门控)────────────────────
264
+ def _recompute_stage(self, pm: ProceduralMemory) -> None:
265
+ if pm.gate_status in ("pending", "rejected"):
266
+ return
267
+ if pm.gate_status == "shadow" and pm.use_count >= MIN_USES_SHADOW:
268
+ if pm.success_rate >= REGRESSION_EPS:
269
+ pm.gate_status = "canary" # 不可逆也到 canary(封顶,需人工放行)
270
+ else:
271
+ pm.gate_status = "rejected" # 回归不达标→拒绝(脏技能防护)
272
+ elif pm.gate_status == "canary" and pm.use_count >= MIN_USES_ACTIVE:
273
+ if pm.success_rate >= REGRESSION_EPS:
274
+ if not pm.irreversible:
275
+ pm.gate_status = "active"
276
+ else:
277
+ pm.gate_status = "rejected" # canary 后期回归→拒绝(防已放行技能劣化)
278
+
279
+ def record_use(self, skill_id: str, success: bool) -> dict:
280
+ """记录一次复用结果,自动触发晋级判定。fail-open。"""
281
+ def _do():
282
+ pm = self._items.get(skill_id)
283
+ if pm is None:
284
+ return {"ok": False, "error": "unknown skill_id"}
285
+ pm.use_count += 1
286
+ if success:
287
+ pm.success_count += 1
288
+ pm.last_used_at = now()
289
+ self._recompute_stage(pm)
290
+ return {"ok": True, "skill_id": skill_id,
291
+ "gate_status": pm.gate_status,
292
+ "success_rate": round(pm.success_rate, 3),
293
+ "requires_human": pm.requires_human}
294
+ return self._with_lock(_do)
295
+
296
+ def promote(self, skill_id: str, by: str = "human") -> dict:
297
+ """人工放行晋级(不可逆技能封顶 canary 后由 ops_gate 同构的人工确认)。"""
298
+ def _do():
299
+ pm = self._items.get(skill_id)
300
+ if pm is None:
301
+ return {"ok": False, "error": "unknown skill_id"}
302
+ if pm.irreversible and pm.gate_status == "canary":
303
+ pm.requires_human = False
304
+ pm.gate_status = "active"
305
+ return {"ok": True, "skill_id": skill_id,
306
+ "gate_status": "active", "promoted_by": by}
307
+ return {"ok": False, "error": f"不可晋级(irreversible={pm.irreversible},status={pm.gate_status})"}
308
+ return self._with_lock(_do)
309
+
310
+ # ── Skill1 任务路由召回(禁向量:结构化 tag 匹配 + 三信用排序)──
311
+ @staticmethod
312
+ def _tag_match(task_query: str, tags: list) -> float:
313
+ """结构化匹配:task_query 子串命中多少 tag(非向量)。"""
314
+ if not task_query or not tags:
315
+ return 0.0
316
+ q = task_query.lower()
317
+ hit = sum(1 for t in tags if t and t.lower() in q)
318
+ return hit / len(tags)
319
+
320
+ def recall_for_task(self, task_query: str, top_k: int = 3) -> list:
321
+ """按任务召回可复用程序性记忆。
322
+
323
+ 候选=usable 技能(active/canary);排序分 = 选择信用(tag匹配)*0.5
324
+ + 利用信用(成功率×时效)*0.5。禁向量,确定性。fail-open 返回 []。
325
+ """
326
+ try:
327
+ cands = [pm for pm in self._items.values() if pm.usable]
328
+ scored = []
329
+ for pm in cands:
330
+ sel = self._tag_match(task_query, pm.task_tags) # 选择信用
331
+ recency = 1.0 if not pm.last_used_at else 0.8 # 简化时效因子
332
+ util = pm.success_rate * recency # 利用信用
333
+ score = 0.5 * sel + 0.5 * util
334
+ if sel <= 0 and util <= 0:
335
+ continue # 既不匹配也无复用记录→不召回(避免噪声)
336
+ scored.append((score, pm))
337
+ scored.sort(key=lambda x: (-x[0], -x[1].success_rate, x[1].skill_id))
338
+ return [pm.as_dict() | {"match_score": round(s, 3)} for s, pm in scored[:top_k]]
339
+ except Exception:
340
+ return []
341
+
342
+ def list_all(self, include_rejected: bool = False) -> list:
343
+ try:
344
+ items = self._items.values()
345
+ if not include_rejected:
346
+ items = [p for p in items if p.gate_status != "rejected"]
347
+ return [p.as_dict() for p in items]
348
+ except Exception:
349
+ return []
350
+
351
+
352
+ # ── 便捷函数(供 server / CLI 直接调用)────────────────────────────────
353
+ def procmem_recall(workspace_root: Path, task_query: str, top_k: int = 3) -> list:
354
+ return ProceduralRegistry(workspace_root).recall_for_task(task_query, top_k)
355
+
356
+
357
+ def procmem_admit(workspace_root: Path, skill_id: str, task_tags: list,
358
+ invocation: str, evidence: str = "", contract: Optional[dict] = None,
359
+ irreversible: bool = False) -> dict:
360
+ return ProceduralRegistry(workspace_root).admit(
361
+ skill_id, task_tags, invocation, evidence, contract, irreversible)