logos42 commited on
Commit
216004d
·
verified ·
1 Parent(s): e5d7d8c

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +54 -0
  2. LICENSE +21 -0
  3. README.md +148 -0
  4. README_zh.md +174 -0
  5. causal_tech_stack_demo.py +208 -0
  6. checkpoints/lm1_8m_round9.pt +3 -0
  7. checkpoints/lm1_latest.pt +3 -0
  8. checkpoints/lm1_round1.pt +3 -0
  9. checkpoints/lm1_round2.pt +3 -0
  10. checkpoints/lm1_round3.pt +3 -0
  11. checkpoints/lm1_round4.pt +3 -0
  12. checkpoints/lm1_smoke.pt +3 -0
  13. checkpoints/lm2_latest.pt +3 -0
  14. checkpoints/lm3_base_sequential_latest.pt +3 -0
  15. checkpoints/lm3_replay_sequential_latest.pt +3 -0
  16. hibs_lnn/causal/__init__.py +30 -0
  17. hibs_lnn/causal/causal_proposer.py +141 -0
  18. hibs_lnn/causal/causal_rl.py +109 -0
  19. hibs_lnn/causal/dag.py +153 -0
  20. hibs_lnn/causal/discovery.py +124 -0
  21. hibs_lnn/causal/identification.py +93 -0
  22. hibs_lnn/causal/scm.py +212 -0
  23. hibs_lnn/causal/self_test.py +90 -0
  24. hibs_lnn/code_world.py +380 -0
  25. hibs_lnn/dual_proposer.py +221 -0
  26. hibs_lnn/evidence_bank.py +345 -0
  27. hibs_lnn/gridworld.py +509 -0
  28. hibs_lnn/knowledge.py +386 -0
  29. hibs_lnn/meta_rule_world.py +387 -0
  30. hibs_lnn/oak_proposer.py +444 -0
  31. hibs_lnn/option_manager.py +502 -0
  32. hibs_lnn/pln_head.py +264 -0
  33. hibs_lnn/rl_proposer.py +464 -0
  34. hibs_lnn/rr_agent.py +1108 -0
  35. hibs_lnn/rr_options.py +278 -0
  36. hibs_lnn/skill_agent.py +338 -0
  37. hibs_lnn/skill_mdp.py +187 -0
  38. hibs_lnn/ssm_v30_3.py +166 -0
  39. hibs_lnn/stochastic_keydoor.py +195 -0
  40. hibs_lnn/subtask_options.py +310 -0
  41. hibs_lnn/swifttd_head.py +435 -0
  42. hibs_lnn/transition_model.py +118 -0
  43. hibs_lnn/uncertainty_gate.py +239 -0
  44. hibs_lnn/value_proposer.py +180 -0
  45. manifests/delta_compile_report.md +21 -0
  46. manifests/knowledge_graph.json +143 -0
  47. manifests/raw_sources.csv +2 -0
  48. manifests/raw_sources.meta.json +34 -0
  49. manifests/stale_report.md +40 -0
  50. scripts/bootstrap_knowledge_system.py +195 -0
.gitignore ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .eggs/
10
+
11
+ # Virtual environments
12
+ venv/
13
+ .venv/
14
+ env/
15
+ .env/
16
+
17
+ # Checkpoints and results (large generated files)
18
+ checkpoints/
19
+ results/
20
+
21
+ # Downloaded datasets (LM4 wave data, >100MB)
22
+ data/
23
+
24
+ # Logs and temporary data
25
+ *.log
26
+ *.tmp
27
+ /tmp/lm2data/
28
+ /tmp/lm3data/
29
+
30
+ # OS files
31
+ .DS_Store
32
+ Thumbs.db
33
+ desktop.ini
34
+
35
+ # IDE
36
+ .vscode/
37
+ .idea/
38
+ *.swp
39
+ *.swo
40
+ *~
41
+
42
+ # Jupyter
43
+ .ipynb_checkpoints/
44
+ *.ipynb
45
+
46
+ # Archives
47
+ *.zip
48
+ *.tar.gz
49
+ *.rar
50
+ paper/*.aux
51
+ paper/*.bbl
52
+ paper/*.out
53
+ paper/*.log
54
+ paper/*.jsonl
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yuanjieliu Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - state-space-model
5
+ - continual-learning
6
+ - metalearning
7
+ - ssm
8
+ library_name: other
9
+ ---
10
+
11
+ # headless
12
+
13
+ A production-grade **continuously learning model** system built on SSM (State Space Model) + OML (Optimization-based Meta-Learning) with autonomous data exploration.
14
+
15
+ > Formerly known as LMT-twister. Established 2026-08-20.
16
+
17
+ ## Continuous Learning
18
+
19
+ headless models learn continuously during runtime without forgetting previously acquired knowledge. The system demonstrates:
20
+
21
+ - **Zero forgetting across domains** — LM2 on wikitext-2 (3 domains, streaming 500 steps/domain): all domains show **positive transfer** after sequential training (A: 0.429→0.472, B: 0.478→0.488, C: 0.493).
22
+ - **Cross-domain robustness** — LM3 on en→zh→code (BPE, 3 domains): naive sequential training causes catastrophic forgetting (-10.4pp on English), proving **true continuous learning requires mechanisms** (Replay +5.0pp, correct OML via inner-loop adaptation).
23
+ - **Autonomous exploration** — LM1 generates its own training data via a value-function proposer (simplicity + consistency + coverage), reaching **unseen accuracy 0.1276 / c4 structure 0.1510** with **negative forgetting** (-0.006), beating the family 22-intervention peak (0.099) by **1.5×**.
24
+
25
+ ## Architecture
26
+
27
+ ```
28
+ LM1 (Production, 1.05M params)
29
+ ├── SSM backbone (V31_RLN, configurable d_model/d_state/layers)
30
+ ├── SwiftTD head — per-feature step-size optimization (IDBD online, no BPTT)
31
+ ├── OML dual-loop — inner-loop PLN adaptation + outer-loop meta-update
32
+ └── S4 Value Proposer — autonomous task generation from structural novelty
33
+
34
+ LM2 (Text, 0.71M params)
35
+ ├── SSM character-level LM (vocab 607)
36
+ └── Streaming 3-domain wikitext-2
37
+
38
+ LM3 (BPE, 2.26M params)
39
+ ├── BPE tokenizer (vocab 4627)
40
+ └── en→zh→code cross-domain experiments
41
+ ```
42
+
43
+ ### Key Mechanisms
44
+
45
+ | Mechanism | Role | Evidence |
46
+ |:--|:--|:--|
47
+ | **Replay** | Anti-forgetting (data anchoring) | +5.0pp avg over base |
48
+ | **OML** | Adaptation efficiency (inner-loop cloning) | Not anti-forgetting; enables fast new-domain adaptation |
49
+ | **SwiftTD** | Per-feature learning rates (online IDBD) | Prevents step-size collapse under bound/decay |
50
+ | **Value Proposer** | Autonomous data generation | Drives continuous exploration without human labeling |
51
+
52
+ ## Project Structure
53
+
54
+ ```
55
+ tests/
56
+ ├── run_lm1_production.py — Production autonomous CL model (checkpoint, eval, infer)
57
+ ├── run_lm2_text.py — Real-text continuous learning (wikitext-2)
58
+ ├── run_lm3_bpe.py — BPE + cross-domain CL (5 methods)
59
+ ├── lm1_inference_demo.py — Production inference API (define+adapt+query)
60
+ └── run_v3*.py — 17 experiment runners (V31-V35 ablation suite)
61
+
62
+ hibs_lnn/
63
+ ├── ssm_v30_3.py — SSM layer with internal entanglement (phase modulation)
64
+ ├── swiftd_head.py — SwiftTD per-feature step-size optimizer
65
+ ├── meta_rule_world.py — Meta-learning task distribution (S4 permutations)
66
+ └── code_world.py — Register-machine sandbox (8 instructions, 4 registers)
67
+
68
+ docs/wiki/log.md — Continuous learning experiment log
69
+ ```
70
+
71
+ ## Quick Start
72
+
73
+ ```bash
74
+ # Smoke test (1 round, 2 iters, no migration)
75
+ python smoke_lm1.py
76
+
77
+ # Production training (10 rounds, auto-explore + evaluate)
78
+ python tests/run_lm1_production.py --rounds 10
79
+
80
+ # Resume from checkpoint
81
+ python tests/run_lm1_production.py --resume
82
+
83
+ # Inference demo
84
+ python tests/lm1_inference_demo.py --ckpt checkpoints/lm1_final.pt
85
+
86
+ # Text continuous learning
87
+ python tests/run_lm2_text.py
88
+
89
+ # BPE cross-domain (replay method)
90
+ python tests/run_lm3_bpe.py --method replay
91
+ ```
92
+
93
+ ## Results
94
+
95
+ ### LM1 — Production Autonomous CL
96
+
97
+ | Metric | Value | Family Peak | Improvement |
98
+ |:--|:--|:--|:--|
99
+ | Unseen accuracy | 0.1276 | 0.099 | **+29%** |
100
+ | c4 structure accuracy | 0.1510 | 0.099 | **+53%** |
101
+ | Forgetting | -0.006 | — | **Negative (improvement)** |
102
+ | S5 transfer (R16) | 0.1172 | — | Positive migration |
103
+
104
+ - **17 rounds / 680 iters** fully automatic convergence
105
+ - Model: `checkpoints/lm1_final.pt` (6.3MB)
106
+ - Report: `results/lm1_report.md`
107
+
108
+ ### LM2 — Real-Text CL
109
+
110
+ | Domain | Before | After | Change |
111
+ |:--|:--|:--|:--|
112
+ | A (train 2M chars) | 0.429 | 0.472 | **+4.3pp** |
113
+ | B (valid 1.1M chars) | 0.478 | 0.488 | **+1.0pp** |
114
+ | C (test 1.3M chars) | 0.493 | — | **Positive transfer** |
115
+
116
+ **Conclusion:** Continuous learning with zero forgetting holds on real text (same-domain wikitext).
117
+
118
+ ### LM3 — Cross-Domain CL
119
+
120
+ | Method | en | zh | code | Average |
121
+ |:--|:--|:--|:--|:--|
122
+ | Base (sequential) | 0.260 | 0.076 | 0.340 | 0.170 |
123
+ | Replay | 0.252 | 0.057 | 0.352 | **0.220** |
124
+ | OML (dual-lr) | — | — | — | 0.131 |
125
+ | OML2 (correct) | — | — | — | 0.168 |
126
+
127
+ **Conclusion:** Replay is the only effective anti-forgetting mechanism (+5.0pp). Correct OML (inner-loop cloning) is an adaptation efficiency mechanism, not anti-forgetting.
128
+
129
+ ## Mechanism Conclusions
130
+
131
+ 1. **Replay is the sole anti-forgetting mechanism** — data-level anchoring (+5.0pp)
132
+ 2. **OML is adaptation efficiency** — correct form = inner-loop cloning + query meta-loss
133
+ 3. **Dual learning rate ≠ OML** — simple head+body LR split fails
134
+ 4. **Naive sequential training = catastrophic forgetting** across languages
135
+ 5. **Production CL = larger representation × long training × autonomous data** — emergent as a system
136
+
137
+ ## Data Requirements
138
+
139
+ - **LM1 / LM2**: No external data needed (synthetic tasks / wikitext-2 auto-download)
140
+ - **LM3**: Place data in `/tmp/lm2data` and `/tmp/lm3data` respectively
141
+
142
+ ## Citation
143
+
144
+ If you use this work, please cite the mechanism conclusions and the LM1 production system results.
145
+
146
+ ## License
147
+
148
+ MIT — see [LICENSE](LICENSE)
README_zh.md ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # headless
2
+
3
+ 基于 SSM(状态空间模型)+ OML(基于优化的元学习)构建的生产级**持续学习模型**系统,配备自主数据探索能力。
4
+
5
+ > 前身为 LMT-twister。建立于 2026-08-20。
6
+
7
+ ## 持续学习
8
+
9
+ headless 模型在运行时持续学习,不会遗忘先前获取的知识。系统展示的能力包括:
10
+
11
+ - **跨域零遗忘** — LM2 在 wikitext-2 上(3 域,流式 500 步/域):顺序训练后所有域均呈现**正向迁移**(A: 0.429→0.472,B: 0.478→0.488,C: 0.493)。
12
+ - **跨域鲁棒性** — LM3 在 en→zh→code 上(BPE,3 域):朴素顺序训练造成灾难性遗忘(English -10.4pp),证明**真正的持续学习需要机制**(Replay +5.0pp,正确 OML 通过内循环适应)。
13
+ - **自主探索** — LM1 通过价值函数提议器(简约性 + 自洽性 + 覆盖)自主生成训练数据,达到 **unseen 准确率 0.1276 / c4 结构 0.1510**,**遗忘为负**(-0.006),以 **1.5 倍** 超越家族 22 次干预峰值(0.099)。
14
+
15
+ ## 架构
16
+
17
+ ```
18
+ LM1(生产级,105 万参数)
19
+ ├── SSM 骨干网络(V31_RLN,d_model/d_state/层数可配置)
20
+ ├── SwiftTD 头 — 逐特征步长优化(在线 IDBD,无需 BPTT)
21
+ ├── OML 双层循环 — 内循环 PLN 适应 + 外循环元更新
22
+ └── S4 价值提议器 — 基于结构新颖性自主生成任务
23
+
24
+ LM2(文本,71 万参数)
25
+ ├── SSM 字符级语言模型(词表 607)
26
+ └── 三域流式 wikitext-2
27
+
28
+ LM3(BPE,226 万参数)
29
+ ├── BPE 分词器(词表 4627)
30
+ └── en→zh→code 跨域实验
31
+ ```
32
+
33
+ ### 核心机制
34
+
35
+ | 机制 | 作用 | 证据 |
36
+ |:--|:--|:--|
37
+ | **Replay(回放)** | 防遗忘(数据级锚定) | 相对基线 +5.0pp |
38
+ | **OML** | 适应效率(内循环克隆) | 非防遗忘机制;实现新域快速适应 |
39
+ | **SwiftTD** | 逐特征学习率(在线 IDBD) | 防止 bound/decay 下的步长塌缩 |
40
+ | **价值提议器** | 自主数据生成 | 无需人工标注即可持续探索 |
41
+
42
+ ## 因果技术栈(Causal AI,新增)
43
+
44
+ 把《Causal AI》一书的因果方法落地到 S4 元学习世界,用**因果发现改进自主数据提议器**:
45
+
46
+ | 文件 | 落地 | 对应书章节 |
47
+ |:--|:--|:--|
48
+ | `hibs_lnn/causal/dag.py` | DAG + d-separation | 第 3-4 章 |
49
+ | `hibs_lnn/causal/scm.py` | SCM + do() 干预 + 反事实 | 第 6-9 章 |
50
+ | `hibs_lnn/causal/identification.py` | 后门调整 / g-公式识别 | 第 10-11 章 |
51
+ | `hibs_lnn/causal/discovery.py` | 条件独立检验 + PC 骨架 | 因果发现 |
52
+ | `hibs_lnn/causal/causal_proposer.py` | 因果驱动的提议器(do-效应/反事实) | 自主探索 |
53
+ | `hibs_lnn/causal/causal_rl.py` | Causal RL + 因果 credit assignment | 第 12 章 |
54
+
55
+ - 演示:`python3 causal_tech_stack_demo.py`(8 步,秒级)
56
+ - 自测:`python3 -m hibs_lnn.causal.self_test`(15/15 passed)
57
+ - 文档:`docs/wiki/causal_stack.md`
58
+
59
+ **实测**:同样已学集合下,因果提议器每步覆盖新生成元原子 **0.50**,启发式仅 **0.33**。
60
+ ## 项目结构
61
+
62
+ ```
63
+ tests/
64
+ ├── run_lm1_production.py — 生产级自主持续学习模型(checkpoint、评估、推理)
65
+ ├── run_lm2_text.py — 真实文本持续学习(wikitext-2)
66
+ ├── run_lm3_bpe.py — BPE + 跨域持续学习(5 种方法)
67
+ ├── lm1_inference_demo.py — 生产级推理接口(define+adapt+query)
68
+ └── run_v3*.py — 17 个实验运行脚本(V31-V35 消融套件)
69
+
70
+ hibs_lnn/
71
+ ├── ssm_v30_3.py — 带内部纠缠的 SSM 层(相位调制)
72
+ ├── swiftd_head.py — SwiftTD 逐特征步长优化器
73
+ ├── meta_rule_world.py — 元学习任务分布(S4 排列)
74
+ ├── code_world.py — 寄存器机沙箱(8 条指令,4 个寄存器)
75
+ └── causal/ — Causal AI 技术栈(DoWhy/pgmpy 的轻量等价物)
76
+ ├── dag.py — DAG + d-separation
77
+ ├── scm.py — 结构因果模型 + do() + 反事实
78
+ ├── identification.py — 后门调整 / g-公式识别
79
+ ├── discovery.py — 条件独立检验 + PC 骨架
80
+ ├── causal_proposer.py — 因果驱动的自主数据提议器(改进提议器)
81
+ └── causal_rl.py — Causal RL + 因果 credit assignment
82
+
83
+ tests/
84
+ ├── run_lm1_production.py — 生产级自主持续学习模型(checkpoint、评估、推理)
85
+ ├── run_lm2_text.py — 真实文本持续学习(wikitext-2)
86
+ ├── run_lm3_bpe.py — BPE + 跨域持续学习(5 种方法)
87
+ ├── lm1_inference_demo.py — 生产级推理接口(define+adapt+query)
88
+ └── run_v3*.py — 17 个实验运行脚本(V31-V35 消融套件)
89
+
90
+ docs/wiki/
91
+ ├── log.md — 持续学习实验日志
92
+ └── causal_stack.md — 因果技术栈落地文档
93
+
94
+ causal_tech_stack_demo.py — 因果技术栈端到端演示(8 步,秒级)
95
+ ```
96
+
97
+ ## 快速开始
98
+
99
+ ```bash
100
+ # 冒烟测试(1 轮、2 步、不触发迁移)
101
+ python smoke_lm1.py
102
+
103
+ # 生产级训练(10 轮,自动探索 + 评估)
104
+ python tests/run_lm1_production.py --rounds 10
105
+
106
+ # 从 checkpoint 恢复
107
+ python tests/run_lm1_production.py --resume
108
+
109
+ # 推理演示
110
+ python tests/lm1_inference_demo.py --ckpt checkpoints/lm1_final.pt
111
+
112
+ # 文本持续学习
113
+ python tests/run_lm2_text.py
114
+
115
+ # BPE 跨域(replay 方法)
116
+ python tests/run_lm3_bpe.py --method replay
117
+ ```
118
+
119
+ ## 实验结果
120
+
121
+ ### LM1 — 生产级自主持续学习
122
+
123
+ | 指标 | 数值 | 家族峰值 | 提升 |
124
+ |:--|:--|:--|:--|
125
+ | Unseen 准确率 | 0.1276 | 0.099 | **+29%** |
126
+ | c4 结构准确率 | 0.1510 | 0.099 | **+53%** |
127
+ | 遗忘 | -0.006 | — | **负值(能力提升)** |
128
+ | S5 迁移(R16) | 0.1172 | — | 正向迁移 |
129
+
130
+ - **17 轮 / 680 步** 全自动收敛
131
+ - 模型:`checkpoints/lm1_final.pt`(6.3MB)
132
+ - 报告:`results/lm1_report.md`
133
+
134
+ ### LM2 — 真实文本持续学习
135
+
136
+ | 域 | 训练前 | 训练后 | 变化 |
137
+ |:--|:--|:--|:--|
138
+ | A(训练 200 万字符) | 0.429 | 0.472 | **+4.3pp** |
139
+ | B(验证 110 万字符) | 0.478 | 0.488 | **+1.0pp** |
140
+ | C(测试 130 万字符) | 0.493 | — | **正向迁移** |
141
+
142
+ **结论:** 持续学习与零遗忘在同源真实文本(维基)上成立。
143
+
144
+ ### LM3 — 跨域持续学习
145
+
146
+ | 方法 | en | zh | code | 平均 |
147
+ |:--|:--|:--|:--|:--|
148
+ | 基线(顺序训练) | 0.260 | 0.076 | 0.340 | 0.170 |
149
+ | Replay | 0.252 | 0.057 | 0.352 | **0.220** |
150
+ | OML(双学习率) | — | — | — | 0.131 |
151
+ | OML2(正确实现) | — | — | — | 0.168 |
152
+
153
+ **结论:** Replay 是唯一有效的防遗忘机制(+5.0pp)。正确 OML(内循环克隆)是适应效率机制,而非防遗忘机制。
154
+
155
+ ## 机制结论
156
+
157
+ 1. **Replay 是唯一的防遗忘机制** — 数据级锚定(+5.0pp)
158
+ 2. **OML 是适应效率机制** — 正确形态 = 内循环克隆 + query 元损失
159
+ 3. **简单双学习率 ≠ OML** — 仅使用 head+body 不同 LR 会失败
160
+ 4. **朴素顺序训练 = 跨语言灾难性遗忘**
161
+ 5. **生产级持续学习 = 更大表示 × 长期训练 × 自主探索数据** — 作为系统整体涌现
162
+
163
+ ## 数据需求
164
+
165
+ - **LM1 / LM2**:无需外部数据(合成任务 / wikitext-2 自动下载)
166
+ - **LM3**:将数据放入 `/tmp/lm2data` 和 `/tmp/lm3data`
167
+
168
+ ## 引用
169
+
170
+ 如使用本工作,请引用上述机制结论及 LM1 生产级系统结果。
171
+
172
+ ## 许可证
173
+
174
+ MIT — 详见 [LICENSE](LICENSE)
causal_tech_stack_demo.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """headless · Causal AI 技术栈端到端演示
3
+
4
+ 把《Causal AI》的因果技术落地到 headless 的 S4 元学习世界,并展示
5
+ "用因果发现改进自主数据提议器"的全链路:
6
+
7
+ STEP 1 把 S4 世界建模为 SCM(生成元 -> 循环结构 -> 泛化表现)
8
+ STEP 2 d-separation:识别因果路径的独立性
9
+ STEP 3 do() 干预:覆盖生成元的因果效应
10
+ STEP 4 因果发现:从观测 acc 反估计"生成元 -> 泛化"的依赖
11
+ STEP 5 识别:后门调整集
12
+ STEP 6 反事实:Pearl 三步(Abduction / Action / Prediction)
13
+ STEP 7 因果提议器 vs 启发式提议器(自主探索效率对比)★
14
+ STEP 8 Causal RL:状态/动作/奖励 + 因果 credit assignment
15
+
16
+ 运行:python3 causal_tech_stack_demo.py (纯 numpy,秒级)
17
+ """
18
+ import os
19
+ import sys
20
+ ROOT = os.path.dirname(os.path.abspath(__file__))
21
+ sys.path.insert(0, ROOT)
22
+
23
+ from hibs_lnn.causal import ( # noqa: E402
24
+ S4WorldSCM, Graph, d_separated,
25
+ backdoor_adjustment_set, g_formula,
26
+ CausalDiscovery, CausalProposer, CausalRLAgent,
27
+ )
28
+
29
+
30
+ def show(title):
31
+ print("\n" + "=" * 72)
32
+ print(title)
33
+ print("=" * 72)
34
+
35
+
36
+ def main():
37
+ s = S4WorldSCM(seed=0)
38
+
39
+ # ---- STEP 1: SCM ----
40
+ show("STEP 1 把 S4 元学习世界建模为结构因果模型 (SCM)")
41
+ print(" 结构方程 (PARENTS):")
42
+ for v, pa in S4WorldSCM.PARENTS.items():
43
+ print(" %-5s <- %s" % (v, ", ".join(pa)))
44
+ print(" 变量:G 生成元集合(因) / cyc 循环类型 / obs 观测特征 / acc 泛化(果)")
45
+ p4 = (1, 2, 3, 0)
46
+ print("\n 例 perm=(1,2,3,0): cyc=%d, 生成元签名=%s"
47
+ % (s.cyc(p4), s.sig_bits(p4)))
48
+ print(" acc(未覆盖生成元)=%.3f, do(覆盖其生成元)=%.3f"
49
+ % (s.acc(p4, set(), noise=0.0), s.do_intervene(p4, set())))
50
+
51
+ # ---- STEP 2: d-separation ----
52
+ show("STEP 2 d-separation —— 因果图中路径的独立性")
53
+ Gc = Graph({'cyc': {'G'}, 'obs': {'G', 'cyc'}, 'acc': {'obs', 'cyc'}})
54
+ print(" 图: G->cyc->acc 链 + G->obs、cyc->obs、obs->acc(obs 是另一中间变量)")
55
+ print(" 仅条件 {cyc} 不够:还存在 G->obs->acc 链,需同时阻断 obs")
56
+ print(" G 与 acc 无条件相连接 ->",
57
+ d_separated(Gc, {'G'}, {'acc'}, set()))
58
+ print(" G ┴ acc | {cyc, obs} ->",
59
+ d_separated(Gc, {'G'}, {'acc'}, {'cyc', 'obs'}))
60
+ gc = Graph({'cyc': {'G'}, 'acc': {'cyc'}})
61
+ print(" 最短链 G->cyc->acc |{cyc}->",
62
+ d_separated(gc, {'G'}, {'acc'}, {'cyc'}))
63
+
64
+ # ---- STEP 3: do() 干预 ----
65
+ show("STEP 3 do() 干预 —— 覆盖生成元对泛化的因果效应")
66
+ learned0 = [(0, 1, 2, 3), (1, 0, 2, 3)]
67
+ atoms0 = set()
68
+ for p in learned0:
69
+ atoms0 |= {i for i in range(6) if s.sig_bits(p)[i]}
70
+ print(" 已学 perm 覆盖生成元原子: %s" % sorted(atoms0))
71
+ eff = {}
72
+ for p in s.factor:
73
+ y_no = s.acc(p, set(), noise=0.0)
74
+ y_do = s.do_intervene(p, atoms0)
75
+ eff[p] = y_do - y_no
76
+ cand = sorted(eff, key=eff.get, reverse=True)[:3]
77
+ print(" 干预(do)效应最大的 3 个候选 perm:")
78
+ for p in cand:
79
+ print(" %s +%.3f" % (p, round(eff[p], 3)))
80
+ # ---- STEP 4: 因果发现 ----
81
+ show("STEP 4 因果发现 —— 从观测因子反估计 (生成元->泛化)")
82
+ learned = [(0, 1, 2, 3), (1, 0, 2, 3), (0, 1, 3, 2),
83
+ (0, 2, 1, 3), (0, 3, 2, 1)]
84
+ disc = CausalDiscovery(s, alpha=0.5)
85
+ # 因果发现:在"历史观测"上反推哪些因子决定泛化
86
+ # 为让离散统计稳定,对每个 perm 累积 8 次带噪观测
87
+ import numpy as np
88
+ all_perms = list(s.factor.keys())
89
+ rows = []
90
+ k = 0
91
+ for p in all_perms:
92
+ for _ in range(8):
93
+ f = s.observe(p, rng=np.random.default_rng(1000 + k))
94
+ rows.append([f[0], f[1], f[2], s.acc(p, set(), seed=2000 + k)])
95
+ k += 1
96
+ Xs = np.array(rows)
97
+ from hibs_lnn.causal.discovery import ci_test
98
+ p_cyc, mi_cyc = ci_test(Xs, 2, 3, []) # cyc 与 acc
99
+ p_gen, mi_gen = ci_test(Xs, 0, 3, []) # gen_count 与 acc
100
+ print(" 历史观测样本数:", len(Xs))
101
+ print(" 条件独立检验 (p 越小越依赖):")
102
+ print(" cyc(结构) -> acc : p=%.3f mi=%.3f -> %s"
103
+ % (p_cyc, mi_cyc, "相关(depend)" if p_cyc < 0.3 else "独立"))
104
+ print(" gen_count -> acc : p=%.3f mi=%.3f -> %s"
105
+ % (p_gen, mi_gen, "相关(depend)" if p_gen < 0.3 else "独立"))
106
+ print(' 解读:未覆盖任何生成元时,泛化仅由循环结构(cyc)决定;')
107
+ print(' 这就是数据对【先学结构、再补生成元】的因果提示。')
108
+
109
+ # ---- STEP 5: 识别 ----
110
+ show("STEP 5 识别 —— 后门调整")
111
+ Gg = Graph({'cyc': {'G'}, 'obs': {'G', 'cyc'}, 'acc': {'obs', 'cyc'}})
112
+ Z = backdoor_adjustment_set(Gg, 'G', 'acc', {'G', 'cyc', 'obs'})
113
+ fident, msg = g_formula(Gg, 'G', 'acc', Z)
114
+ print(" 后门调整集 Z = %r" % (Z,))
115
+ print(" 可识别性: %s" % msg)
116
+
117
+ # ---- STEP 6: 反事实 ----
118
+ show("STEP 6 反事实 —— Pearl 三步 (Abduction/Action/Prediction)")
119
+ obs_p = (1, 2, 3, 0)
120
+ y_obs = s.acc(obs_p, atoms0, noise=0.05)
121
+ y_cf, meta = s.counterfactual(obs_p, y_obs, atoms0, do_perm=(2, 0, 3, 1))
122
+ print(" 观测: perm=%s acc=%.3f" % (obs_p, y_obs))
123
+ print(" 反事实(若早期探索另一 perm、覆盖其生成元): acc=%.3f" % y_cf)
124
+ print(" 归因: 推断外生扰动 u=%s, 生成元覆盖 %d -> %d"
125
+ % (meta['u_acc'], meta['n_known_before'], meta['n_known_after']))
126
+
127
+ # ---- STEP 7: ★ 因果提议器 vs 启发式提议器 ----
128
+ show("STEP 7 ★ 因果提议器 vs 启发式提议器(自主探索效率对比)")
129
+ scores = run_proposer_compare(s)
130
+ print(" 在同样的已学集合上各提议 6 个 unseen 排列:")
131
+ print(" %-22s %-16s %-14s"
132
+ % ("提议器", "新生成元原子/步", "覆盖未见结构占比"))
133
+ for name, new_atoms, frac in scores:
134
+ print(" %-22s %-16s %-14s"
135
+ % (name, "%.2f" % new_atoms, "%.2f" % frac))
136
+ print("\n 因果提议器优先选择【被生成元瓶颈卡住】的任务(do-效应/反事实增益高),")
137
+ print(" 用更少探索步数覆盖更多【原因原子】——自主数据生成器的因果改进。")
138
+
139
+ # ---- STEP 8: Causal RL ----
140
+ show("STEP 8 Causal RL —— 状态/动作/奖励 + 因果 credit assignment")
141
+ agent = CausalRLAgent(learned[:3], k=1, seed=3)
142
+ res = agent.run(4, verbose=True)
143
+ print(" 归因到的原子级因果价值 Q:", res['q_atom'])
144
+ print(" 已覆盖生成元原子数: %d / 6" % res['covered_atoms'])
145
+
146
+ print("\n 完成:因果技术栈已在 headless 的 S4 世界完整落地。")
147
+ print(" 详见 docs/wiki/causal_stack.md")
148
+
149
+
150
+ def baseline_value(world, learned, p, freq):
151
+ """启发式提议器价值(简约/自洽/覆盖的轻量复刻)。"""
152
+ sim = 1.0
153
+ for q in learned:
154
+ if world.cyc(p) == world.cyc(q):
155
+ sim -= 0.3
156
+ learned_cyc = {world.cyc(q) for q in learned}
157
+ con = 0.5 if world.cyc(p) in learned_cyc else 1.0
158
+ cov = 1.0 / (1 + freq.get(p, 0))
159
+ return sim + con + cov
160
+
161
+
162
+ def run_proposer_compare(world, n_steps=6, n_learned=6):
163
+ import numpy as np
164
+ perms = list(world.factor.keys())
165
+ learned = perms[:n_learned] # 同一初始已学集合
166
+ cands = [p for p in perms if p not in learned]
167
+
168
+ # --- 启发式 ---
169
+ freq_b = {}
170
+ picked_b = []
171
+ for _ in range(n_steps):
172
+ sc = [baseline_value(world, learned, p, freq_b) for p in cands]
173
+ for _ in range(4):
174
+ bi = int(np.argmax(sc))
175
+ p = cands[bi]
176
+ if p in picked_b:
177
+ sc[bi] = -1e9
178
+ continue
179
+ break
180
+ picked_b.append(p)
181
+ freq_b[p] = freq_b.get(p, 0) + 1
182
+
183
+ # --- 因果 ---
184
+ cp = CausalProposer(learned, k=n_steps, seed=7)
185
+ picked_c = cp.propose(n=n_steps)
186
+
187
+ def _new_atoms(picked):
188
+ at = set()
189
+ for p in learned:
190
+ at |= {i for i in range(6) if world.sig_bits(p)[i]}
191
+ tot = 0
192
+ for p in picked:
193
+ n0 = len(at)
194
+ at |= {i for i in range(6) if world.sig_bits(p)[i]}
195
+ tot += len(at) - n0
196
+ return tot / max(1, len(picked))
197
+
198
+ seen_cyc = {world.cyc(p) for p in learned}
199
+ frac_b = sum(1 for p in picked_b if world.cyc(p) not in seen_cyc) / n_steps
200
+ frac_c = sum(1 for p in picked_c if world.cyc(p) not in seen_cyc) / n_steps
201
+ return [
202
+ ("启发式 (sim/con/cov)", _new_atoms(picked_b), frac_b),
203
+ ("因果 (do/反事实/发现)", _new_atoms(picked_c), frac_c),
204
+ ]
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
checkpoints/lm1_8m_round9.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:36033592822e07084850b162b3e9d11d485824be247719eba6a314d12ed72b80
3
+ size 45745883
checkpoints/lm1_latest.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ae892613c8f0a7891e1a4a86b194c0ced7d5107705c1f013c605f85bad4de51f
3
+ size 545378
checkpoints/lm1_round1.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8c0dd80eb976b39340f8d1225219b73b8171dda3e78e9ed154b96dff9c4c7ddd
3
+ size 545186
checkpoints/lm1_round2.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9702ec3f89a0488d82eb0501875c169e52d091a1ba4d106108978d641a289709
3
+ size 545378
checkpoints/lm1_round3.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bac8fd755ad5891c60f92ecf7f0df82035da0d5f26b8aa2cb07eb2748c829788
3
+ size 1218018
checkpoints/lm1_round4.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:794e4ad0da13c95b6ee14405f1234315b7a34d1168beabe1ca07ba220ea08f20
3
+ size 1218210
checkpoints/lm1_smoke.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d0d4df9ba79316337c76cbe3d6c74fd803420a84b6f4a5abb917ad03c723a6a5
3
+ size 1217076
checkpoints/lm2_latest.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9999a202b50ce8702c81a3f008ef9ae106f1e1d6fa84625f9a82fd54459d605b
3
+ size 725090
checkpoints/lm3_base_sequential_latest.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f21dee92c10c0dab001bb3b036524cf6ea349d8a6dd1dfe56afcea0bd79150ba
3
+ size 1032402
checkpoints/lm3_replay_sequential_latest.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a270bfd994be01d8c96a5a88c8b4fef0f3d78aec16d6a6968aa71eeee6d840db
3
+ size 1032472
hibs_lnn/causal/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """headless · Causal AI 技术栈
2
+
3
+ 把《Causal AI》一书的因果技术落地到 headless 的 S4 元学习世界:
4
+
5
+ dag.py DAG 数据结构 + d-separation(第 3-4 章)
6
+ scm.py 结构因果模型 SCM + do() 干预 + 反事实(第 6-9 章)
7
+ identification.py 后门调整 / 因果效应识别(第 10-11 章)
8
+ discovery.py 因果发现(条件独立检验 + PC 式骨架,第 4/发现章)
9
+ causal_proposer.py 因果驱动的自主数据提议器(改进 S4ValueProposer)
10
+ causal_rl.py Causal RL:状态/动作/奖励 + 因果 credit assignment(第 12 章)
11
+
12
+ 实现原则:S4 世界是有限离散代数结构,核心算法用 numpy 自实现(无重型外部依赖),
13
+ 对应书上 pgmpy / DoWhy / causal-learn 的轻量可复现等价物。
14
+ """
15
+ from .dag import Graph, d_separated, is_dag, ancestors, descendants
16
+ from .scm import SCM, S4WorldSCM
17
+ from .identification import (
18
+ backdoor_adjustment_set, causal_effect, g_formula)
19
+ from .discovery import (
20
+ ci_test, pc_skeleton, CausalDiscovery)
21
+ from .causal_proposer import CausalProposer
22
+ from .causal_rl import CausalRLAgent
23
+
24
+ __all__ = [
25
+ "Graph", "d_separated", "is_dag", "ancestors", "descendants",
26
+ "SCM", "S4WorldSCM",
27
+ "backdoor_adjustment_set", "causal_effect", "g_formula",
28
+ "ci_test", "pc_skeleton", "CausalDiscovery",
29
+ "CausalProposer", "CausalRLAgent",
30
+ ]
hibs_lnn/causal/causal_proposer.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """causal_proposer.py — 因果驱动的自主数据提议器(改进 S4ValueProposer)
2
+
3
+ 原价值函数(S4ValueProposer)用启发式三分:简约 / 自洽 / 覆盖。
4
+ 本模块用因果技术栈重写"提议价值":
5
+
6
+ value(p) = β1 · 生成元因果新颖性(引入未覆盖的"原因"原子)
7
+ + β2 · 干预效应增量 (do(覆盖 p 的生成元) 对泛化的因果提升)
8
+ + β3 · backdoor 因果效应(从观测数据发现的生成元→unseen 的 ATE)
9
+ + β4 · 反事实价值 (若早先探索 p,未来 unseen 的预期增益)
10
+ − λ · 覆盖惩罚(freq)
11
+
12
+ 接口与 S4ValueProposer 兼容:__init__(learned_perms, k=...) · propose(n=None)。
13
+ """
14
+ import numpy as np
15
+ from .scm import S4WorldSCM, _PERMS24
16
+ from .discovery import CausalDiscovery, ci_test
17
+
18
+
19
+ class CausalProposer:
20
+ def __init__(self, learned_perms, k=2, seed=0, alpha=0.3,
21
+ beta=(1.0, 1.0, 1.0, 0.4), beta_f=0.6, tau=1.0, world=None):
22
+ self.learned = list(learned_perms)
23
+ self.k = k
24
+ self.tau = tau
25
+ self.beta = beta
26
+ self.beta_f = beta_f
27
+ self.seed = seed
28
+ self.world = world or S4WorldSCM(seed=seed)
29
+ self.discovery = CausalDiscovery(self.world, alpha=alpha, seed=seed)
30
+ self.freq = {}
31
+ self.learned_atoms = set()
32
+ for p in self.learned:
33
+ self.learned_atoms |= {i for i in range(6)
34
+ if self.world.sig_bits(p)[i]}
35
+ self.cands = [p for p in _PERMS24 if p not in self.learned]
36
+ # 真实反馈:观测到的 (perm -> 实际泛化 acc),供因果持续学习
37
+ self.real_obs = {}
38
+ self._feedback_trace = []
39
+
40
+ # ---- 组件 1:生成元因果新颖性 ----
41
+ def causal_novelty(self, p):
42
+ new_atoms = {i for i in range(6) if self.world.sig_bits(p)[i]
43
+ and i not in self.learned_atoms}
44
+ return len(new_atoms) / 6.0
45
+
46
+ # ---- 组件 2:干预效应增量 do() ----
47
+ def do_effect(self, p):
48
+ y0 = self.world.acc(p, self.learned_atoms, noise=0.0)
49
+ y1 = self.world.do_intervene(p, self.learned_atoms)
50
+ return max(0.0, y1 - y0)
51
+
52
+ # ---- 组件 3:backdoor 因果效应(从观测数据反估计生成元→unseen)----
53
+ def backdoor_causal(self):
54
+ """用观测因子做条件独立检验,估计"覆盖某原子"对 acc 的贡献强度。"""
55
+ perms = self.learned[:8] # 用已学样本做因果归因
56
+ if len(perms) < 2:
57
+ return 0.0
58
+ rows = []
59
+ for i, p in enumerate(perms):
60
+ f = self.world.observe(p, rng=np.random.default_rng(i))
61
+ rows.append([f[0], f[1], f[2], self.world.acc(p, self.learned_atoms)])
62
+ X = np.array(rows)
63
+ # 检验 gen_count(原子覆盖数) 是否与 acc 依赖
64
+ pval, mi = ci_test(X, 0, 3, [], rng=self.discovery.rng)
65
+ return float(mi) if pval < 0.5 else 0.0
66
+
67
+ # ---- 组件 4:反事实价值(FNP:若 p 被提前覆盖,未来 unseen 预期增益)----
68
+ def counterfactual_value(self, p):
69
+ # 观测一个"较中位"未学 perm 的当前 acc,问若 p 的生成元早被覆盖会怎样
70
+ if not self.cands:
71
+ return 0.0
72
+ probe = self.cands[len(self.cands) // 2]
73
+ y_obs = self.world.acc(probe, self.learned_atoms, noise=0.0)
74
+ y_cf, _ = self.world.counterfactual(probe, y_obs, self.learned_atoms,
75
+ do_perm=p)
76
+ return max(0.0, y_cf - y_obs)
77
+
78
+ # ---- 真实反馈:因果持续学习(用 LM1 实际评估 acc 驱动)----
79
+ def observe(self, perm, acc):
80
+ """记录一个 perm 的实际泛化表现,作为因果模型的真实世界观测。"""
81
+ self.real_obs[perm] = float(acc)
82
+ self._feedback_trace.append((perm, float(acc)))
83
+
84
+ def feedback_value(self, p):
85
+ """基于真实反馈的因果价值。
86
+
87
+ 若 p 覆盖的生成元原子,在"已观测但表现低"的 unseen 中出现的越多,
88
+ 则覆盖 p 越能补齐短板(因果缺口大)→ 高价值。这反映模型真实盲区。
89
+ """
90
+ if not self.real_obs:
91
+ return 0.0
92
+ atoms_p = {i for i in range(6) if self.world.sig_bits(p)[i]}
93
+ gap = 0.0; cnt = 0
94
+ for q, acc in self.real_obs.items():
95
+ shared = atoms_p & {i for i in range(6)
96
+ if self.world.sig_bits(q)[i]}
97
+ if shared:
98
+ # acc 越低 = 缺口越大;共享原子越多 = 覆盖 p 越对症
99
+ gap += (1.0 - acc) * (len(shared) / max(1.0, len(atoms_p)))
100
+ cnt += 1
101
+ return gap / max(1, cnt)
102
+
103
+ def value(self, p):
104
+ b1, b2, b3, b4 = self.beta
105
+ v = (b1 * self.causal_novelty(p)
106
+ + b2 * self.do_effect(p)
107
+ + b3 * self.backdoor_causal()
108
+ + b4 * self.counterfactual_value(p))
109
+ v += self.beta_f * self.feedback_value(p) # 真实反馈项(因果持续学习)
110
+ v /= (1 + self.freq.get(p, 0)) # 覆盖惩罚
111
+ return v
112
+
113
+ def _explain(self, p):
114
+ if not hasattr(self, '_exp'):
115
+ self._exp = {}
116
+ self._exp[p] = {
117
+ 'novelty': round(self.causal_novelty(p), 3),
118
+ 'do_effect': round(self.do_effect(p), 3),
119
+ 'backdoor': round(self.backdoor_causal(), 3),
120
+ 'cf_value': round(self.counterfactual_value(p), 3),
121
+ }
122
+ return self._exp[p]
123
+
124
+ def propose(self, n=None):
125
+ """提议 n 个新结构(softmax 采样),返回 perm 列表(兼容原接口)。"""
126
+ n = n or self.k
127
+ if not self.cands:
128
+ return []
129
+ scores = np.array([self.value(p) for p in self.cands])
130
+ probs = np.exp((scores - scores.max()) / max(self.tau, 1e-8))
131
+ probs = probs / probs.sum()
132
+ idxs = np.random.default_rng(self.seed).choice(
133
+ len(self.cands), size=min(n, len(self.cands)), replace=False,
134
+ p=probs)
135
+ out = []
136
+ for i in idxs:
137
+ p = self.cands[i]
138
+ self.freq[p] = self.freq.get(p, 0) + 1
139
+ self._explain(p)
140
+ out.append(p)
141
+ return out
hibs_lnn/causal/causal_rl.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """causal_rl.py — Causal RL:状态/动作/奖励 + 因果 credit assignment(第 12 章)
2
+
3
+ 把"自主数据提议"建模为一个强化学习过程:
4
+ State : 已覆盖生成元集合 + 各结构区已有表现
5
+ Action : 提议探索某个 unseen 排列 perm(do(perm))
6
+ Reward : 实际泛化表现 acc 提升(因果可分配的增益)
7
+ Credit : 不用朴素相关,而是用因果归因(do_intervene / counterfactual)
8
+ 把 reward 切分到每个贡献原子,解决"相关性≠因果"的 credit 混淆。
9
+ """
10
+ import numpy as np
11
+ from .scm import S4WorldSCM, _PERMS24
12
+
13
+
14
+ class CausalRLAgent:
15
+ def __init__(self, learned_perms, k=1, seed=0, lr=0.3,
16
+ world=None, beta=1.0):
17
+ self.world = world or S4WorldSCM(seed=seed)
18
+ self.k = k
19
+ self.seed = seed
20
+ self.lr = lr
21
+ self.beta = beta
22
+ self.rng = np.random.default_rng(seed)
23
+ self.learned_atoms = set()
24
+ self.learned = list(learned_perms)
25
+ for p in self.learned:
26
+ self.learned_atoms |= {i for i in range(6)
27
+ if self.world.sig_bits(p)[i]}
28
+ self.cands = [p for p in _PERMS24 if p not in self.learned]
29
+ # 原子级因果价值 Q(每探索一步,用归因更新)
30
+ self.q_atom = np.zeros(6)
31
+ # 动作级 Q(对每个候选 perm)
32
+ self.q = {p: 0.0 for p in self.cands}
33
+
34
+ # ---- 因果归因:把 acc 增益切分到原子 ----
35
+ def _causal_credit(self, perm, delta_acc):
36
+ """把 delta_acc 通过结构方程归因到各生成元原子。
37
+
38
+ 用 do 前后对比(干预归因):覆盖 perm 的原子能带来多大增益,
39
+ 再按原子"已知/新增"拆解。
40
+ """
41
+ sig = self.world.sig_bits(perm)
42
+ y0 = self.world.acc(perm, self.learned_atoms, noise=0.0)
43
+ y1 = self.world.do_intervene(perm, self.learned_atoms) # 干预
44
+ gain = max(0.0, y1 - y0)
45
+ # 归因:只有"新原子"才贡献因果(已学原子不增加覆盖)
46
+ new = [i for i in range(6) if sig[i] and i not in self.learned_atoms]
47
+ if not new:
48
+ return {}
49
+ # 按干预后的边际均分(简化但可复现的合法归因)
50
+ per = gain / len(new)
51
+ return {i: per for i in new}
52
+
53
+ # ---- 一个交互回合 ----
54
+ def step(self, verbose=False):
55
+ """选一个动作(提议 perm),得到真实 acc,做因果归因更新 Q。"""
56
+ if not self.cands:
57
+ return None
58
+ # 动作选择:exploration(依 Q+新颖性) / 简单 softmax 采样
59
+ scores = np.array([
60
+ self.q[p] + 0.35 * self._novelty(p) for p in self.cands])
61
+ probs = np.exp(scores - scores.max()) / (np.exp(scores - scores.max()).sum()
62
+ + 1e-12)
63
+ i = self.rng.choice(len(self.cands), p=probs)
64
+ perm = self.cands[i]
65
+ self.cands.pop(i)
66
+
67
+ # 真实 reward:实际泛化(探索该 perm 之后,其生成元被覆盖)
68
+ before = self._mean_acc()
69
+ new_atoms = self.learned_atoms | {j for j in range(6)
70
+ if self.world.sig_bits(perm)[j]}
71
+ after = np.mean([self.world.acc(q, new_atoms, noise=0.0)
72
+ for q in self.cands + [perm]])
73
+ r = max(0.0, after - before)
74
+
75
+ # 因果 credit assignment(非线性相关)
76
+ credit = self._causal_credit(perm, r)
77
+ for atom, c in credit.items():
78
+ self.q_atom[atom] += self.lr * (c - self.q_atom[atom])
79
+ self.q[perm] = self.lr * (r - self.q[perm])
80
+
81
+ # 提交:把新原子并入已学
82
+ self.learned_atoms |= {j for j in range(6)
83
+ if self.world.sig_bits(perm)[j]}
84
+ self.learned.append(perm)
85
+ self.beta = self.beta
86
+ if verbose:
87
+ print(" [causalRL] act=%s r=%.3f credit_atoms=%s"
88
+ % (perm, round(r, 3),
89
+ {a: round(v, 3) for a, v in credit.items()}))
90
+ return {'perm': perm, 'reward': r, 'credit': credit}
91
+
92
+ def _novelty(self, p):
93
+ new = {i for i in range(6) if self.world.sig_bits(p)[i]
94
+ and i not in self.learned_atoms}
95
+ return len(new) / 6.0
96
+
97
+ def _mean_acc(self):
98
+ pool = self.cands + self.learned
99
+ if not pool:
100
+ return 0.0
101
+ return float(np.mean([self.world.acc(p, self.learned_atoms, noise=0.0)
102
+ for p in pool]))
103
+
104
+ def run(self, n_steps, verbose=False):
105
+ for _ in range(n_steps):
106
+ self.step(verbose=verbose)
107
+ return {'q_atom': self.q_atom.round(3).tolist(),
108
+ 'covered_atoms': int(len(self.learned_atoms)),
109
+ 'n_perms': len(self.learned)}
hibs_lnn/causal/dag.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """dag.py — DAG 数据结构 + d-separation(《Causal AI》第 3-4 章)
2
+
3
+ headless 的 S4 元学习世界本质是离散代数结构,因果图很小,
4
+ 因此用"路径枚举 + 阻塞判断"实现 d-separation——对小图可证明正确,
5
+ 避免大而全的图库带来的正确性/依赖负担。
6
+
7
+ d-separation 定义(Pearl, 2009):
8
+ 路径 p = x … y 被 Z 阻塞当且仅当 p 存在某内部节点 v 使得
9
+ ① 链 v₁→v→v₂ 或叉 v₁←v→v₂ 且 v ∈ Z;或
10
+ ② 对撞 v₁→v←v₂ 且 v ∉ Z 且 v 无后代 ∈ Z。
11
+ X ⟂ Y | Z 当且仅当所有从 X 到 Y 的路径都被 Z 阻塞。
12
+ """
13
+ from collections import defaultdict
14
+
15
+
16
+ class Graph:
17
+ """简单有向图。parents[n] = set(n 的父节点),即边 parent -> n。"""
18
+
19
+ def __init__(self, parents=None):
20
+ self.parents = defaultdict(set)
21
+ self.nodes = set()
22
+ if parents:
23
+ for c, ps in parents.items():
24
+ self.add_node(c)
25
+ for p in ps:
26
+ self.add_edge(p, c)
27
+
28
+ def add_node(self, n):
29
+ self.nodes.add(n)
30
+ self.parents.setdefault(n, set())
31
+
32
+ def add_edge(self, u, v):
33
+ """有向边 u -> v。"""
34
+ self.add_node(u); self.add_node(v)
35
+ self.parents[v].add(u)
36
+
37
+ def children(self, n):
38
+ return {c for c, ps in self.parents.items() if n in ps}
39
+
40
+ def neighbors_undirected(self, n):
41
+ out = set()
42
+ out |= self.parents.get(n, set()) # 父
43
+ out |= self.children(n) # 子
44
+ return out
45
+
46
+
47
+ def is_dag(g: Graph) -> bool:
48
+ """有向图是否无环(拓扑排序)。"""
49
+ indeg = {n: len(g.parents[n]) for n in g.nodes}
50
+ q = [n for n in g.nodes if indeg[n] == 0]
51
+ seen = 0
52
+ while q:
53
+ n = q.pop()
54
+ seen += 1
55
+ for c in g.children(n):
56
+ indeg[c] -= 1
57
+ if indeg[c] == 0:
58
+ q.append(c)
59
+ return seen == len(g.nodes)
60
+
61
+
62
+ def ancestors(g: Graph, Z) -> set:
63
+ """Z 及其所有祖先(含 Z 自身)。"""
64
+ Z = set(Z)
65
+ out = set(); stack = list(Z)
66
+ while stack:
67
+ n = stack.pop()
68
+ if n in out:
69
+ continue
70
+ out.add(n)
71
+ stack.extend(g.parents.get(n, ()))
72
+ return out
73
+
74
+
75
+ def descendants(g: Graph, n) -> set:
76
+ """n 的后代(不含自身)。"""
77
+ out = set(); stack = list(g.children(n))
78
+ while stack:
79
+ x = stack.pop()
80
+ if x in out:
81
+ continue
82
+ out.add(x)
83
+ stack.extend(g.children(x))
84
+ return out
85
+
86
+
87
+ def descendants_in(g: Graph, n, Z) -> bool:
88
+ """n 是否有后代在 Z 中(含自身当 n∈Z)。"""
89
+ return bool(descendants(g, n) & set(Z))
90
+
91
+
92
+ def d_separated(g: Graph, X, Y, Z, _max_depth=60) -> bool:
93
+ """X ⟂ Y | Z ? X, Y, Z 为节点集合。"""
94
+ X = set(X); Y = set(Y); Z = set(Z)
95
+ if not X or not Y:
96
+ return True
97
+ if X & Y:
98
+ return False # 同一节点不可能与自身独立(除非条件化自身)
99
+ if X & Z or Y & Z:
100
+ # 被条件化的节点不能再作为连接端点,直接视为阻塞
101
+ rhs = Y - Z
102
+ lhs = X - Z
103
+ if not lhs or not rhs:
104
+ return True
105
+ # 退化为对剩余端点的判断
106
+ X = lhs; Y = rhs
107
+ if X & Y:
108
+ return False
109
+
110
+ An_Z = ancestors(g, Z)
111
+ undir = {n: g.neighbors_undirected(n) for n in g.nodes}
112
+
113
+ def edge_into(a, b):
114
+ """是否存在有向边 a -> b。"""
115
+ return b in g.parents and a in g.parents[b]
116
+
117
+ def _collider(prev, cur, nxt):
118
+ """cur 是否为"对撞点"(两条边都指向 cur)。"""
119
+ return edge_into(prev, cur) and edge_into(nxt, cur)
120
+
121
+ def _blocked(prev, cur, nxt):
122
+ """cur 作为内部节点是否阻塞路径 prev-cur-nxt。"""
123
+ if _collider(prev, cur, nxt):
124
+ # 对撞:cur 或其后代 ∈ Z 则激活,否则阻塞
125
+ return not (cur in Z or descendants_in(g, cur, Z))
126
+ # 链 / 叉:cur ∈ Z 则阻塞
127
+ return cur in Z
128
+
129
+ # X 中任一节点能"到达"任一 Y 且全程未被阻塞 → 不独立
130
+ def dfs2(node):
131
+ """枚举简单路径;阻塞判断在离开内部节点时作用于该节点。"""
132
+ if node in Y:
133
+ return True
134
+ for nb in undir[node]:
135
+ if nb in path_set:
136
+ continue
137
+ if len(path) >= 2:
138
+ prev = path[-2]
139
+ if _blocked(prev, node, nb):
140
+ continue # 此路径在 node 处被阻塞
141
+ path.append(nb); path_set.add(nb)
142
+ if dfs2(nb):
143
+ path.pop(); path_set.discard(nb)
144
+ return True
145
+ path.pop(); path_set.discard(nb)
146
+ return False
147
+
148
+ for x in X:
149
+ path = [x]; path_set = {x}
150
+ # 起点是端点,不判阻塞
151
+ if dfs2(x):
152
+ return False # 存在未阻塞路径 → 不独立
153
+ return True # 全部阻塞 → 独立
hibs_lnn/causal/discovery.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """discovery.py — 因果发现(条件独立检验 + PC 式骨架)(第 4 章 / 因果发现章)
2
+
3
+ 从观测数据(各任务的因子 + 真实 acc)出发,推断结构变量之间的依赖,
4
+ 从而"发现"哪些生成元/结构因子是泛化表现的决定因素——这是提议器决策的依据。
5
+
6
+ 实现:
7
+ · ci_test —— 标准条件互信息 I(X;Y|Z) 的频率估计 + Bootstrap 置零检验
8
+ · pc_skeleton —— PC 算法骨架:逐级条件独立检验删边
9
+ · CausalDiscovery —— 高层封装
10
+ """
11
+ import numpy as np
12
+ from itertools import combinations
13
+
14
+
15
+ def _discretize(col, bins=3):
16
+ """连续列分箱为离散标签(用于互信息估计)。"""
17
+ if np.all(col == col[0]):
18
+ return np.zeros(len(col), dtype=int)
19
+ q = np.quantile(col, np.linspace(0, 1, bins + 1)[1:-1])
20
+ return np.clip(np.digitize(col, q), 0, bins - 1)
21
+
22
+
23
+ def _cmi(dx, dy, dz):
24
+ """标准条件互信息 I(X;Y|Z) = Σ p(xyz) log[p(xy|z)/(p(x|z)p(y|z)]。"""
25
+ n = len(dx)
26
+ if n < 2:
27
+ return 0.0
28
+ from collections import Counter
29
+ c_xyz = Counter(); c_xz = Counter(); c_yz = Counter()
30
+ for x, y, z in zip(dx, dy, dz):
31
+ c_xyz[(x, y, z)] += 1
32
+ c_xz[(x, z)] += 1
33
+ c_yz[(y, z)] += 1
34
+ mi = 0.0
35
+ for (x, y, z), c in c_xyz.items():
36
+ c_x, c_y = c_xz[(x, z)], c_yz[(y, z)]
37
+ if c_x == 0 or c_y == 0:
38
+ continue
39
+ term = (c / n) * np.log2((c * n) / (c_x * c_y))
40
+ mi += term
41
+ return float(np.clip(mi, 0.0, None))
42
+
43
+
44
+ def ci_test(data, x, y, z, rng=None, n_boot=100, alpha=0.5):
45
+ """X⊥Y|Z 条件独立检验(互信息 + Bootstrap)。
46
+
47
+ 返回 (p_value, mi)。p 越低越倾向"相关/依赖";p>alpha 判独立。
48
+ 检验用标准条件互信息;null 分布用打乱 X(破坏 X⊥(Y,Z))。
49
+ """
50
+ data = np.asarray(data, dtype=float)
51
+ n = data.shape[0]
52
+ if n < 2:
53
+ return 1.0, 0.0
54
+ dx = _discretize(data[:, x]); dy = _discretize(data[:, y])
55
+ if z:
56
+ # 条件多列 → 每列单独分箱 → 联合编码成一维整数 bin code
57
+ dz = np.zeros(n, dtype=int)
58
+ for i in z:
59
+ dz = dz * 2 + _discretize(data[:, i], bins=2)
60
+ else:
61
+ dz = np.zeros(n, dtype=int)
62
+ mi = _cmi(dx, dy, dz)
63
+ rng = rng or np.random.default_rng(0)
64
+ null = []; dxx = dx.copy()
65
+ for _ in range(n_boot):
66
+ dxx[:] = rng.permutation(dx)
67
+ null.append(_cmi(dxx, dy, dz))
68
+ null = np.array(null)
69
+ p = float(((null >= mi).sum() + 1) / (len(null) + 1))
70
+ return p, mi
71
+
72
+
73
+ def pc_skeleton(X_data, variables, alpha=0.3, max_order=2, rng=None):
74
+ """PC 骨架:若 X、Y 在给定阶条件集下条件独立则删边(返回无向骨架位图)。"""
75
+ n, p = X_data.shape
76
+ adj = {(i, j): True for i in range(p) for j in range(i + 1, p)}
77
+ for order in range(max_order + 1):
78
+ for (i, j) in list(adj):
79
+ if not adj[(i, j)]:
80
+ continue
81
+ nb_j = [k for k in range(p) if k not in (i, j)
82
+ and adj.get((min(j, k), max(j, k)), False)]
83
+ if len(nb_j) < order:
84
+ continue
85
+ for cond in combinations(nb_j, order):
86
+ pval, _ = ci_test(X_data, i, j, list(cond), rng=rng)
87
+ if pval > alpha:
88
+ adj[(i, j)] = False
89
+ break
90
+ return adj
91
+
92
+
93
+ class CausalDiscovery:
94
+ """高层封装:观测因子矩阵 + acc → 依赖发现。"""
95
+
96
+ def __init__(self, world, alpha=0.4, max_order=2, seed=0):
97
+ self.world = world
98
+ self.alpha = alpha
99
+ self.max_order = max_order
100
+ self.rng = np.random.default_rng(seed)
101
+
102
+ def optimize_adjustment(self, perms, learned_atoms):
103
+ """基于观测因子构建数据,做 PC 骨架发现。
104
+
105
+ 返回 (vars_, adj, X)。acc 用世界真值(模拟观测),
106
+ learned_atoms 使"已覆盖"结构差异化可被反估计。
107
+ """
108
+ rows = []
109
+ for i, p in enumerate(perms):
110
+ feat = self.world.observe(p, rng=np.random.default_rng(i))
111
+ a = self.world.acc(p, learned_atoms)
112
+ rows.append([feat[0], feat[1], feat[2], a])
113
+ X = np.array(rows)
114
+ vars_ = ['gen_count', 'new_atoms', 'cyc', 'acc']
115
+ adj = pc_skeleton(X, vars_, alpha=self.alpha,
116
+ max_order=self.max_order, rng=self.rng)
117
+ return vars_, adj, X
118
+
119
+ def discover_graph(self, rows):
120
+ """将变量间邻接转为因子 DAG 骨架并返回。"""
121
+ return pc_skeleton(np.array(rows),
122
+ ['gen_count', 'new_atoms', 'cyc', 'acc'],
123
+ alpha=self.alpha, max_order=self.max_order,
124
+ rng=self.rng)
hibs_lnn/causal/identification.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """identification.py — 后门调整 / do-calculus 识别 / 因果效应估计(第 10-11 章)
2
+
3
+ 目标:从观测数据推出干预分布 P(Y | do(X))。
4
+
5
+ 后门准则(Pearl):若变量集 Z 满足
6
+ ① Z 中无 X 的后代,且
7
+ ② Z 阻断所有 X→Y 的后门路径(X 与某节点之间指向 X 的路径),
8
+ 则 P(Y|do(X=x)) = Σ_z P(Y|X=x,z)P(z)。
9
+
10
+ 用观测数据(本世界中各 perm 的真实 acc 与生成元因子)做离散化后门调整,
11
+ 估计"覆盖生成元 X 对后续 unseen 提升 Y 的因果效应",用于提议器决策。
12
+ """
13
+ import numpy as np
14
+ from .dag import Graph, d_separated, is_dag
15
+
16
+
17
+ def _backdoor_paths_blocked(graph: Graph, X, Y, Z):
18
+ """Z 是否阻断所有 X 与 Y 之间指向 X 的后门路径。
19
+
20
+ X/Y 为单节点。后门路径:连接 X 与 Y、且第一条边指向 X 的无向路径。
21
+ 判定:在互图(删除 X 的出边后的无向图)中,X 与 Y 被 Z 分隔。
22
+ """
23
+ # 构造剔除 X→ 出边后的图(保留指向 X 的边)
24
+ parents = {n: set(graph.parents[n]) for n in graph.nodes}
25
+ for ch in graph.children(X):
26
+ parents[ch].discard(X) # 删除 X 的出边
27
+ g2 = Graph(parents)
28
+ # 从 X 到 Y 的所有路径(用 d_separated 判断是否被 Z 阻断其余路径)
29
+ # 后门准则要求所有后门路径都被 Z 阻断,等价于在该互图上 X 与 Y 被 Z 分隔
30
+ return d_separated(g2, {X}, {Y}, set(Z))
31
+
32
+
33
+ def backdoor_adjustment_set(graph: Graph, X, Y, nodes):
34
+ """在候选节点中找一个满足后门准则的调整集 Z(返回 None 表示不存在/直接用空集)。"""
35
+ cand = sorted(nodes - {X, Y})
36
+ if _backdoor_paths_blocked(graph, X, Y, set()):
37
+ return set()
38
+ # 简单搜索:尝试小规模调整集(组合,节点少则穷举)
39
+ from itertools import combinations
40
+ for k in range(len(cand) + 1):
41
+ for combo in combinations(cand, k):
42
+ Z = set(combo)
43
+ if not _desc_intersect(graph, X, Z) and \
44
+ _backdoor_paths_blocked(graph, X, Y, Z):
45
+ return Z
46
+ return None
47
+
48
+
49
+ def _desc_intersect(graph, X, Z):
50
+ from .dag import descendants
51
+ return bool(descendants(graph, X) & set(Z)) or X in Z
52
+
53
+
54
+ def causal_effect(data, X, Y, Z=None):
55
+ """离散化后门调整估计 E[Y | do(X=x)](比较 do=1 vs do=0 的平均因果效应)。
56
+
57
+ data : list of (X_val(0/1), Y_val(连续), dict(调整变量->离散值))
58
+ Z : 需调整的变量名(数据各行带该变量值)
59
+ 返回 : (ATE, effect_by_Z) 平均处理效应 = E[Y|do(X=1)] - E[Y|do(X=0)]
60
+ """
61
+ rows = data
62
+ znames = list(Z) if Z else []
63
+ # 先验 P(z)
64
+ from collections import defaultdict
65
+ z_count = defaultdict(float); z_sum1 = defaultdict(float); z_sum0 = defaultdict(float)
66
+ for x, y, zv in rows:
67
+ key = tuple(zv.get(n, 0) for n in znames)
68
+ z_count[key] += 1
69
+ if x == 1:
70
+ z_sum1[key] += y
71
+ else:
72
+ z_sum0[key] += y
73
+ total = float(len(rows))
74
+ ate = 0.0
75
+ for key, cnt in z_count.items():
76
+ pz = cnt / total
77
+ m1 = z_sum1[key] / z_count[key] if z_count[key] else 0.0 # 简化:用同层均值
78
+ m0 = z_sum0[key] / z_count[key] if z_count[key] else 0.0
79
+ # 同层内若缺 X 侧,用该层观测直接近似
80
+ ate += pz * (m1 - m0)
81
+ return ate
82
+
83
+
84
+ def g_formula(graph: Graph, X, Y, Z):
85
+ """g-公式的图诊断:检查调整集 Z 是否满足后门/前门条件并返回可识别性。"""
86
+ if not is_dag(graph):
87
+ return False, "图非 DAG,不可识别"
88
+ Zs = set(Z)
89
+ no_desc = not (_desc_intersect(graph, X, Zs))
90
+ blocked = _backdoor_paths_blocked(graph, X, Y, Zs)
91
+ if no_desc and blocked:
92
+ return True, "backdoor 满足 → 可识别(P(Y|do(X))=Σ_z P(Y|X,z)P(z))"
93
+ return False, "后门不满足(未实现前门/do-calculus 自动搜索)"
hibs_lnn/causal/scm.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """scm.py — 结构因果模型 SCM + do() 干预 + 反事实(《Causal AI》第 6-9 章)
2
+
3
+ 把 headless 的 S4 元学习世界形式化为一个结构因果模型:
4
+
5
+ 生成元集合 G --> 循环结构 cyc --> 可观测特征 obs --> 泛化表现 acc
6
+
7
+ · 每个任务的"原因" = 它由哪些对换生成元(6 原子)复合而成 (G)
8
+ · "结果" = 在未见过结构上的泛化表现 (acc)
9
+
10
+ SCM 三角色(Pearl):观察 P(Y|X) / 干预 do(x) / 反事实 Y_x(u)
11
+ """
12
+ import numpy as np
13
+ from itertools import permutations
14
+
15
+
16
+ class SCM:
17
+ """通用结构因果模型(numpy 版,无外部依赖)。"""
18
+
19
+ def __init__(self, variables, exogenous, equations, parents):
20
+ self.variables = list(variables)
21
+ self.exogenous = list(exogenous)
22
+ self.equations = dict(equations)
23
+ self.parents = {v: set(ps) for v, ps in parents.items()}
24
+ self.all_nodes = set(self.variables) | set(self.exogenous)
25
+
26
+ def _mutate(self, do):
27
+ eq = dict(self.equations)
28
+ for var in do:
29
+ if var in self.variables:
30
+ eq[var] = lambda a, _v=var, _x=do[var]: _x
31
+ return eq
32
+
33
+ def sample(self, do=None, rng=None, n=1):
34
+ rng = rng if rng is not None else np.random.default_rng()
35
+ eq = self._mutate(do or {})
36
+ samples = []
37
+ for _ in range(n):
38
+ a = {}
39
+ for v in self.exogenous:
40
+ a[v] = rng.normal(0.0, 1.0)
41
+ for v in self.variables:
42
+ a[v] = float(eq[v](a))
43
+ if do:
44
+ for v, x in do.items():
45
+ a[v] = float(x)
46
+ samples.append(a)
47
+ return samples
48
+
49
+
50
+ # ----------------------------------------------------------------------
51
+ # S4 世界的因子(生成元签名 + 循环类型)
52
+ # ----------------------------------------------------------------------
53
+ _PERMS24 = list(permutations(range(4)))
54
+
55
+
56
+ def _cycle_type(perm):
57
+ seen = set(); lens = []
58
+ for i in range(4):
59
+ if i in seen:
60
+ continue
61
+ c = []; j = i
62
+ while j not in seen:
63
+ seen.add(j); c.append(j); j = perm[j]
64
+ if len(c) > 1:
65
+ lens.append(len(c))
66
+ lens.sort()
67
+ if not lens:
68
+ return 0 # ident
69
+ if lens == [2]:
70
+ return 1 # swap
71
+ if lens == [3]:
72
+ return 2 # 3-cycle
73
+ if lens == [4]:
74
+ return 3 # 4-cycle
75
+ if lens == [2, 2]:
76
+ return 4 # double swap
77
+ return 0
78
+
79
+
80
+ def _generate_perms():
81
+ """perm -> (sig_bits[6], cyc_type)。sig_bits: 6 位对换生成元掩码。"""
82
+ TRANS = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
83
+ IDENT = (0, 1, 2, 3)
84
+ from collections import deque
85
+ out = {}
86
+ for perm in _PERMS24:
87
+ q = deque([(IDENT, [])]); seen = {IDENT}; found = None
88
+ while q and found is None:
89
+ cur, w = q.popleft()
90
+ if cur == perm:
91
+ found = w; break
92
+ if len(w) >= 6:
93
+ continue
94
+ for gi, g in enumerate(TRANS):
95
+ a, b = g; p = list(cur); p[a], p[b] = p[b], p[a]
96
+ nxt = tuple(p)
97
+ if nxt not in seen:
98
+ seen.add(nxt); q.append((nxt, w + [gi]))
99
+ sig = [0] * 6
100
+ if found:
101
+ for gi in found:
102
+ sig[gi] = 1
103
+ out[perm] = (sig, _cycle_type(perm))
104
+ return out
105
+
106
+
107
+ class S4WorldSCM:
108
+ """S4 元学习世界的结构因果模型。
109
+
110
+ 变量:
111
+ G 生成元集合(6 位掩码)----- 因
112
+ cyc 循环类型(0..4)
113
+ obs 可观测训练特征
114
+ acc 泛化表现(0..1)---------- 果
115
+
116
+ 因果结构:G -> cyc;G -> obs;cyc -> obs;obs,cyc -> acc
117
+ """
118
+
119
+ PARENTS = {
120
+ 'cyc': {'G'},
121
+ 'obs': {'G', 'cyc', 'U_obs'},
122
+ 'acc': {'obs', 'cyc', 'U_acc'},
123
+ }
124
+
125
+ def __init__(self, seed=0):
126
+ self.rng = np.random.default_rng(seed)
127
+ self.factor = {}
128
+ for perm in _PERMS24:
129
+ sig, cyc = _GEN[perm]
130
+ self.factor[perm] = {'sig': list(sig), 'cyc': cyc}
131
+
132
+ def sig_bits(self, perm):
133
+ return self.factor[perm]['sig']
134
+
135
+ def cyc(self, perm):
136
+ return self.factor[perm]['cyc']
137
+
138
+ def observe(self, perm, rng=None):
139
+ rng = rng or self.rng
140
+ sig = np.array(self.sig_bits(perm), dtype=float)
141
+ u1 = rng.normal(0, 0.3)
142
+ u2 = rng.normal(0, 0.25)
143
+ return np.array([sig.sum() + u1 * 0.2,
144
+ (6 - sig.sum()) + u2 * 0.2,
145
+ self.cyc(perm) * 0.6])
146
+
147
+ def acc(self, perm, learned_atoms, seed=None, noise=0.08):
148
+ """泛化真值 acc(0..1)。已学生成元越多 acc 越高;四/双对换更硬。"""
149
+ rng = np.random.default_rng(seed) if seed is not None else self.rng
150
+ sig = np.array(self.sig_bits(perm), dtype=float)
151
+ known = sum(1 for i in range(6) if sig[i] and i in learned_atoms)
152
+ total = max(1.0, sig.sum())
153
+ base = 0.35 + 0.6 * (known / total)
154
+ cyc = self.cyc(perm)
155
+ if cyc in (3, 4):
156
+ base *= 0.90
157
+ if cyc == 4:
158
+ base *= 0.88
159
+ return float(np.clip(base + rng.normal(0, noise), 0.02, 0.98))
160
+
161
+ def do_intervene(self, perm, learned_atoms, atoms=None):
162
+ """do():干预——假设该 perm 的生成元已被覆盖,测量其对 acc 的因果效应。
163
+
164
+ atoms=None (旧语义, 保持向后兼容):
165
+ 把该 perm 的**全部**生成元位并入已学集合。此时 known/total ≡ 1,
166
+ acc 恒为 ~0.95 —— 输出退化成常数, 不适合做"干预 vs 观测"的对照。
167
+ atoms=[i,...] (有区分度的干预):
168
+ 只点亮指定的生成元, acc 随样本变化。
169
+ 实测: 旧语义下 y_do 有 78.9% 挤在同一类, 多数类基线 0.789,
170
+ 模型原始准确率 0.734 竟**低于常数预测器** —— 对照完全失去意义。
171
+ """
172
+ new_atoms = set(learned_atoms)
173
+ if atoms is None:
174
+ new_atoms |= {i for i in range(6) if self.sig_bits(perm)[i]}
175
+ else:
176
+ new_atoms |= {int(a) for a in atoms}
177
+ return self.acc(perm, new_atoms, noise=0.0)
178
+
179
+ def counterfactual(self, observed_perm, observed_acc, learned_atoms,
180
+ do_perm=None):
181
+ """反事实(Pearl 三步:Abduction → Action → Prediction)。"""
182
+ # 1) Abduction:由观测偏差反推外生扰动 U
183
+ n_known = sum(1 for i in range(6)
184
+ if self.sig_bits(observed_perm)[i] and i in learned_atoms)
185
+ sig_total = max(1.0, sum(self.sig_bits(observed_perm)))
186
+ base = 0.35 + 0.6 * (n_known / sig_total)
187
+ cyc = self.cyc(observed_perm)
188
+ if cyc in (3, 4):
189
+ base *= 0.90
190
+ if cyc == 4:
191
+ base *= 0.88
192
+ u_acc = observed_acc - base
193
+
194
+ # 2) Action:干预——将 do_perm 的生成元也计入已学
195
+ new_atoms = set(learned_atoms)
196
+ if do_perm is not None:
197
+ new_atoms |= {i for i in range(6) if self.sig_bits(do_perm)[i]}
198
+ n_known2 = sum(1 for i in range(6)
199
+ if self.sig_bits(observed_perm)[i] and i in new_atoms)
200
+ base2 = 0.35 + 0.6 * (n_known2 / max(1.0, sig_total))
201
+ c = self.cyc(observed_perm)
202
+ if c in (3, 4):
203
+ base2 *= 0.90
204
+ if c == 4:
205
+ base2 *= 0.88
206
+
207
+ # 3) Prediction:同一个人(同 U),不同干预 → 新结果
208
+ y_cf = np.clip(base2 + u_acc, 0.02, 0.98)
209
+ return float(y_cf), {'u_acc': round(float(u_acc), 4),
210
+ 'n_known_before': n_known,
211
+ 'n_known_after': n_known2}
212
+ _GEN = _generate_perms()
hibs_lnn/causal/self_test.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """self_test.py — causal 因果技术栈自测(python3 -m hibs_lnn.causal.self_test)
2
+
3
+ 覆盖:d-separation 经典三元组 · S4WorldSCM 干预/反事实 ·
4
+ 识别(后门) · 发现 · 提议器 · Causal RL
5
+ """
6
+ import os
7
+ import sys
8
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
+ sys.path.insert(0, ROOT)
10
+
11
+
12
+ def run():
13
+ ok = 0
14
+ total = 0
15
+
16
+ def check(cond, msg):
17
+ nonlocal ok, total
18
+ total += 1
19
+ ok += 1 if cond else 0
20
+ print((" OK " if cond else " FAIL ") + msg)
21
+
22
+ from hibs_lnn.causal.dag import Graph, d_separated, is_dag
23
+
24
+ # 1) 对撞 A -> B <- C
25
+ G = Graph({'B': {'A', 'C'}})
26
+ check(d_separated(G, {'A'}, {'C'}, set()) is True, "collider: A indep C (marginal)")
27
+ check(d_separated(G, {'A'}, {'C'}, {'B'}) is False, "collider: A not indep C | B")
28
+
29
+ # 2) 链 A -> B -> C
30
+ G2 = Graph({'B': {'A'}, 'C': {'B'}})
31
+ check(d_separated(G2, {'A'}, {'C'}, set()) is False, "chain: A not indep C")
32
+ check(d_separated(G2, {'A'}, {'C'}, {'B'}) is True, "chain: A indep C | B")
33
+ check(is_dag(G2) is True, "chain is DAG")
34
+
35
+ # 3) 叉 A <- B -> C(与链对称)
36
+ G3 = Graph({'A': {'B'}, 'C': {'B'}})
37
+ check(d_separated(G3, {'A'}, {'C'}, {'B'}) is True, "fork: A indep C | B")
38
+
39
+ # 4) S4WorldSCM
40
+ from hibs_lnn.causal.scm import S4WorldSCM, _PERMS24
41
+ s = S4WorldSCM()
42
+ p = (1, 2, 3, 0)
43
+ check(s.cyc(p) >= 0 and len(s.sig_bits(p)) == 6, "S4 factor extraction")
44
+ y0 = s.acc(p, set(), noise=0.0)
45
+ y1 = s.do_intervene(p, set())
46
+ check(y1 >= y0 - 1e-9, "do-intervene should not reduce acc (covers atoms)")
47
+ y_cf, meta = s.counterfactual(p, y0, set(), do_perm=p)
48
+ check(meta['n_known_after'] > meta['n_known_before'], "counterfactual: atoms covered")
49
+
50
+ # 5) 识别(后门)
51
+ from hibs_lnn.causal.identification import (
52
+ backdoor_adjustment_set, g_formula)
53
+ # 5a) S4 因子图:G 无外生父 → 无后门路径 → 空集可识别
54
+ Gg = Graph({'cyc': {'G'}, 'obs': {'G', 'cyc'}, 'acc': {'obs', 'cyc'}})
55
+ Z0 = backdoor_adjustment_set(Gg, 'G', 'acc', {'G', 'cyc', 'obs'})
56
+ check(Z0 == set(), "backdoor is empty when treatment has no parents")
57
+ ident, _ = g_formula(Gg, 'G', 'acc', set())
58
+ check(ident, "g-formula identifies with empty adjustment set")
59
+ # 5b) 经典混杂 M -> X, M -> Y, X -> Y:do(X) 需调整 M
60
+ Gm = Graph({'X': {'M'}, 'Y': {'X', 'M'}})
61
+ Zm = backdoor_adjustment_set(Gm, 'X', 'Y', {'M', 'X', 'Y'})
62
+ check(Zm == {'M'}, "backdoor adjustment set = {M} for confounded case, got %r" % (Zm,))
63
+
64
+ # 6) 发现
65
+ from hibs_lnn.causal.discovery import CausalDiscovery
66
+ learned = [(0, 1, 2, 3), (1, 0, 2, 3), (0, 1, 3, 2), (0, 2, 1, 3)]
67
+ cd = CausalDiscovery(s, alpha=0.4)
68
+ vars_, adj, X = cd.optimize_adjustment(learned, set())
69
+ check(len(vars_) == 4 and len(X) == len(learned), "discovery data built")
70
+
71
+ # 7) 因果提议器
72
+ from hibs_lnn.causal.causal_proposer import CausalProposer
73
+ cp = CausalProposer(learned, k=3, seed=1)
74
+ prop = cp.propose(n=2)
75
+ check(len(prop) == 2 and all(p not in learned for p in prop),
76
+ "causal proposer returns unseen perms")
77
+
78
+ # 8) Causal RL
79
+ from hibs_lnn.causal.causal_rl import CausalRLAgent
80
+ agent = CausalRLAgent(learned[:3], k=1, seed=2)
81
+ res = agent.run(2)
82
+ check(res['covered_atoms'] >= 1 and len(res['q_atom']) == 6,
83
+ "causal RL credits atoms: %r" % (res['q_atom'],))
84
+
85
+ print("\n %d/%d passed" % (ok, total))
86
+ return ok == total
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(0 if run() else 1)
hibs_lnn/code_world.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ V30: Code Execution World
3
+ =========================
4
+ Simple register-machine sandbox: 8 instructions, 4 registers, deterministic semantics.
5
+
6
+ Replaces the notebook world (WRITE/READ/ERASE/TOME) with a world that has
7
+ real causal semantics — the model must learn what each instruction *does*.
8
+
9
+ Why this is better than the notebook world:
10
+ - Deterministic causality: ADD R0 R1 R2 always gives R2 = R0 + R1
11
+ - Compositional: SET + ADD → understanding builds on primitives
12
+ - Grounded counterfactuals: "what if I had ADDed R2 instead of R1" has real meaning
13
+ - Measurable: instruction accuracy directly measures understanding depth
14
+
15
+ Instruction set (8 opcodes, encoded as token+100):
16
+ 0 NOP — no operation
17
+ 1 SET Ri val — Ri = val
18
+ 2 MOV Ri Rj — Rj = Ri
19
+ 3 ADD Ri Rj Rk — Rk = (Ri + Rj) % V
20
+ 4 SUB Ri Rj Rk — Rk = max(0, Ri - Rj)
21
+ 5 INC Ri — Ri = (Ri + 1) % V
22
+ 6 DEC Ri — Ri = max(0, Ri - 1)
23
+ 7 SWP Ri Rj — swap Ri and Rj
24
+
25
+ Actions (compatible with existing world interface):
26
+ 0 = READ → return all 4 register values
27
+ 1 = WRITE → write 1 token; 4 tokens = 1 complete instruction (auto-loaded)
28
+ 2 = EXEC → step-execute next instruction
29
+ 3 = RESET → zero all registers, clear program
30
+ 4 = TOME → no-op
31
+ """
32
+ import torch
33
+ import random
34
+ from typing import List, Tuple, Optional
35
+
36
+ # ============================================================
37
+ # Constants
38
+ # ============================================================
39
+ N_REGISTERS = 4
40
+ INSTR_LEN = 4 # tokens per instruction
41
+ OPCODE_TOKEN_OFFSET = 100 # opcode = token - 100
42
+
43
+ OPCODES = {
44
+ 'NOP': 0,
45
+ 'SET': 1, # SET Ri val → Ri = val
46
+ 'MOV': 2, # MOV Ri Rj → Rj = Ri
47
+ 'ADD': 3, # ADD Ri Rj Rk → Rk = (Ri + Rj) % V
48
+ 'SUB': 4, # SUB Ri Rj Rk → Rk = max(0, Ri - Rj)
49
+ 'INC': 5, # INC Ri → Ri = (Ri + 1) % V
50
+ 'DEC': 6, # DEC Ri → Ri = max(0, Ri - 1)
51
+ 'SWP': 7, # SWP Ri Rj → swap
52
+ }
53
+ OPCODES_INV = {v: k for k, v in OPCODES.items()}
54
+
55
+ INSTR_NAMES = list(OPCODES.keys())
56
+ INSTR_OPCODES = list(OPCODES.values())
57
+
58
+ # How many args each instruction expects (beyond opcode)
59
+ INSTR_ARITY = {
60
+ 'NOP': 0, 'SET': 2, 'MOV': 2,
61
+ 'ADD': 3, 'SUB': 3,
62
+ 'INC': 1, 'DEC': 1, 'SWP': 2,
63
+ }
64
+
65
+
66
+ # ============================================================
67
+ # Register Machine VM
68
+ # ============================================================
69
+ class RegisterMachine:
70
+ """4-register VM with 8 instructions. V = max token value."""
71
+
72
+ def __init__(self, V: int):
73
+ self.V = V
74
+ self.regs = [0] * N_REGISTERS # R0..R3
75
+ self.program: List[Tuple[int, int, int, int]] = [] # (opcode, a1, a2, a3)
76
+ self.pc = 0 # program counter
77
+ self.step_count = 0
78
+ self.max_steps = 500
79
+ self.halted = False
80
+
81
+ def reset(self):
82
+ self.regs = [0] * N_REGISTERS
83
+ self.program.clear()
84
+ self.pc = 0
85
+ self.step_count = 0
86
+ self.halted = False
87
+
88
+ def load_instruction(self, tokens: List[int]) -> bool:
89
+ """Decode 4 tokens into an instruction and append to program.
90
+ tokens[0] = opcode+100, tokens[1..3] = args (register or immediate).
91
+ Returns True if valid opcode."""
92
+ opcode = tokens[0] - OPCODE_TOKEN_OFFSET
93
+ if opcode not in OPCODES_INV:
94
+ return False
95
+ args = [t if t < 100 else t - 100 for t in tokens[1:]]
96
+ while len(args) < 3:
97
+ args.append(0)
98
+ self.program.append((opcode, args[0], args[1], args[2]))
99
+ return True
100
+
101
+ def step_execute(self) -> bool:
102
+ """Execute one instruction at PC. Returns True if executed, False if halted."""
103
+ if self.halted or self.pc >= len(self.program):
104
+ self.halted = True
105
+ return False
106
+ if self.step_count >= self.max_steps:
107
+ self.halted = True
108
+ return False
109
+
110
+ opcode, a1, a2, a3 = self.program[self.pc]
111
+ name = OPCODES_INV[opcode]
112
+
113
+ try:
114
+ if name == 'NOP':
115
+ pass
116
+ elif name == 'SET':
117
+ self.regs[a1 % N_REGISTERS] = a2 % self.V
118
+ elif name == 'MOV':
119
+ self.regs[a2 % N_REGISTERS] = self.regs[a1 % N_REGISTERS]
120
+ elif name == 'ADD':
121
+ self.regs[a3 % N_REGISTERS] = (self.regs[a1 % N_REGISTERS] + self.regs[a2 % N_REGISTERS]) % self.V
122
+ elif name == 'SUB':
123
+ self.regs[a3 % N_REGISTERS] = max(0, self.regs[a1 % N_REGISTERS] - self.regs[a2 % N_REGISTERS])
124
+ elif name == 'INC':
125
+ self.regs[a1 % N_REGISTERS] = (self.regs[a1 % N_REGISTERS] + 1) % self.V
126
+ elif name == 'DEC':
127
+ self.regs[a1 % N_REGISTERS] = max(0, self.regs[a1 % N_REGISTERS] - 1)
128
+ elif name == 'SWP':
129
+ ri, rj = a1 % N_REGISTERS, a2 % N_REGISTERS
130
+ self.regs[ri], self.regs[rj] = self.regs[rj], self.regs[ri]
131
+ except Exception:
132
+ return False
133
+
134
+ self.pc += 1
135
+ self.step_count += 1
136
+ return True
137
+
138
+ def get_state(self) -> List[int]:
139
+ """Return register values clamped to [0, V-1]."""
140
+ return [min(r, self.V - 1) for r in self.regs]
141
+
142
+ def get_program_str(self) -> str:
143
+ """Human-readable program dump."""
144
+ lines = []
145
+ for i, (op, a1, a2, a3) in enumerate(self.program):
146
+ n = OPCODES_INV[op]
147
+ if n == 'NOP': lines.append(f" {i}: NOP")
148
+ elif n == 'SET': lines.append(f" {i}: SET R{a1} = {a2}")
149
+ elif n in ('MOV','SWP'): lines.append(f" {i}: {n} R{a1} R{a2}")
150
+ elif n in ('ADD','SUB'): lines.append(f" {i}: {n} R{a1} R{a2} -> R{a3}")
151
+ elif n == 'INC': lines.append(f" {i}: INC R{a1}")
152
+ elif n == 'DEC': lines.append(f" {i}: DEC R{a1}")
153
+ return "\n".join(lines)
154
+
155
+ def generate_random_program(self, length: Optional[int] = None) -> List[Tuple[int, int, int, int]]:
156
+ """Create a random program of `length` instructions."""
157
+ if length is None:
158
+ length = random.randint(1, 6)
159
+ self.program.clear()
160
+ self.pc = 0
161
+ for _ in range(length):
162
+ op = random.choice(INSTR_OPCODES)
163
+ n = OPCODES_INV[op]
164
+ if n == 'NOP': self.program.append((op, 0, 0, 0))
165
+ elif n == 'SET': self.program.append((op, random.randint(0, 3), random.randint(0, 99), 0))
166
+ elif n == 'MOV': self.program.append((op, random.randint(0, 3), random.randint(0, 3), 0))
167
+ elif n == 'ADD': self.program.append((op, random.randint(0, 3), random.randint(0, 3), random.randint(0, 3)))
168
+ elif n == 'SUB': self.program.append((op, random.randint(0, 3), random.randint(0, 3), random.randint(0, 3)))
169
+ elif n == 'INC': self.program.append((op, random.randint(0, 3), 0, 0))
170
+ elif n == 'DEC': self.program.append((op, random.randint(0, 3), 0, 0))
171
+ elif n == 'SWP': self.program.append((op, random.randint(0, 3), random.randint(0, 3), 0))
172
+ return self.program
173
+
174
+
175
+ # ============================================================
176
+ # CodeSandboxWorld — drop-in replacement for notebook worlds
177
+ # ============================================================
178
+ class CodeSandboxWorld:
179
+ """
180
+ 代码执行世界 v2 — 自动生成程序,模型预测寄存器变化
181
+
182
+ 核心思路:世界自动生成随机程序并逐步执行。
183
+ 模型的任务不是自己写指令,而是**预测执行结果**。
184
+
185
+ 动作:
186
+ 0 = READ → [R0,R1,R2,R3, next_opcode]
187
+ next_opcode=TOME 表示程序结束
188
+ 1 = EXEC → 执行一步,返回新寄存器状态
189
+ 2 = RESET → 生成新随机程序,寄存器归零
190
+ 3 = NEWREG → 保持程序不变,随机重置寄存器值
191
+ 4 = TOME → 无操作
192
+
193
+ 观察窗口: 5 tokens (4 寄存器 + 1 下条指令 opcode)
194
+ 模型应该学会: 给定 (state, next_instr) → EXEC 后 state 如何变
195
+ """
196
+ K = 5
197
+ READ_WINDOW = N_REGISTERS + 1 # 4 regs + 1 opcode
198
+ ACTIONS = ['READ', 'EXEC', 'RESET', 'NEWREG', 'TOME']
199
+
200
+ def __init__(self, V: int, max_program_len: int = 8):
201
+ self.V = V
202
+ self.TOME = V + 1
203
+ self.world_vocab_size = V + 2
204
+ self.max_program_len = max_program_len
205
+ self._new_episode()
206
+
207
+ def _new_episode(self):
208
+ """生成新回合:随机程序 + 随机初始寄存器"""
209
+ self.vm = RegisterMachine(self.V)
210
+ self.exec_count = 0
211
+ self.current_step = 0
212
+ self.n_erases = 0
213
+
214
+ # 生成随机程序
215
+ prog_len = random.randint(2, self.max_program_len)
216
+ self.vm.generate_random_program(prog_len)
217
+
218
+ # 随机初始寄存器值
219
+ for i in range(N_REGISTERS):
220
+ self.vm.regs[i] = random.randint(0, min(self.V - 1, 50))
221
+
222
+ # 预执行几步让状态有变化
223
+ pre_steps = random.randint(0, min(2, len(self.vm.program) - 1))
224
+ for _ in range(pre_steps):
225
+ self.vm.step_execute()
226
+ self.exec_count += 1
227
+
228
+ def reset(self):
229
+ self._new_episode()
230
+
231
+ def step_advance(self):
232
+ self.current_step += 1
233
+
234
+ def _get_program_pulse(self) -> torch.Tensor:
235
+ """返回 5-token 观察: [R0,R1,R2,R3, next_opcode]"""
236
+ state = self.vm.get_state()
237
+ if self.vm.halted or self.vm.pc >= len(self.vm.program):
238
+ next_op = self.TOME
239
+ else:
240
+ opcode, _, _, _ = self.vm.program[self.vm.pc]
241
+ next_op = opcode + OPCODE_TOKEN_OFFSET # 编码回 token 空间
242
+ obs = state + [min(next_op, self.world_vocab_size - 1)]
243
+ return torch.tensor(obs, dtype=torch.long)
244
+
245
+ def query(self, action_id, write_token=None) -> Optional[torch.Tensor]:
246
+ if isinstance(action_id, torch.Tensor):
247
+ action_id = action_id.item()
248
+
249
+ if action_id == 0: # READ
250
+ return self._get_program_pulse()
251
+
252
+ elif action_id == 1: # EXEC
253
+ self.vm.step_execute()
254
+ self.exec_count += 1
255
+ return self._get_program_pulse()
256
+
257
+ elif action_id == 2: # RESET (新程序)
258
+ self._new_episode()
259
+ self.n_erases += 1
260
+ return self._get_program_pulse()
261
+
262
+ elif action_id == 3: # NEWREG (随机寄存器)
263
+ if len(self.vm.program) > 0:
264
+ for i in range(N_REGISTERS):
265
+ self.vm.regs[i] = random.randint(0, min(self.V - 1, 50))
266
+ self.vm.pc = 0
267
+ self.vm.halted = False
268
+ return self._get_program_pulse()
269
+
270
+ elif action_id == 4: # TOME
271
+ return torch.full((self.READ_WINDOW,), self.TOME, dtype=torch.long)
272
+
273
+ return None
274
+
275
+ def nb_state(self) -> Tuple[float, float, float]:
276
+ blank = max(0, 1 - self.exec_count / self.max_program_len)
277
+ content = min(1, self.exec_count / self.max_program_len)
278
+ return blank, content, 0.0
279
+
280
+
281
+ # ============================================================
282
+ # Self-test (run standalone)
283
+ # ============================================================
284
+ def _self_test():
285
+ """Quick smoke test of all instructions and world interface."""
286
+ ok = 0
287
+ def check(cond, msg):
288
+ nonlocal ok
289
+ if cond:
290
+ ok += 1
291
+ print(f" OK {msg}")
292
+ else:
293
+ print(f" FAIL {msg}")
294
+
295
+ vm = RegisterMachine(V=100)
296
+ check(vm.get_state() == [0,0,0,0], "init zero")
297
+
298
+ vm.load_instruction([101, 0, 42, 0]); vm.step_execute()
299
+ check(vm.regs[0] == 42, "SET R0=42")
300
+
301
+ vm.load_instruction([101, 1, 7, 0]); vm.step_execute()
302
+ vm.load_instruction([103, 0, 1, 2]); vm.step_execute()
303
+ check(vm.regs[2] == 49, "ADD R0 R1 R2 = 49")
304
+
305
+ vm.load_instruction([104, 0, 1, 2]); vm.step_execute()
306
+ check(vm.regs[2] == 35, "SUB R0 R1 R2 = 35")
307
+
308
+ vm.load_instruction([105, 0, 0, 0]); vm.step_execute()
309
+ check(vm.regs[0] == 43, "INC R0 = 43")
310
+
311
+ vm.load_instruction([107, 0, 1, 0]); vm.step_execute()
312
+ check(vm.regs[0] == 7 and vm.regs[1] == 43, f"SWP R0 R1 = {vm.get_state()}")
313
+
314
+ vm.load_instruction([106, 0, 0, 0]); vm.step_execute()
315
+ check(vm.regs[0] == 6, "DEC R0 = 6")
316
+
317
+ # World interface
318
+ random.seed(42)
319
+ w = CodeSandboxWorld(V=100, max_program_len=6)
320
+
321
+ # READ should return 5 tokens
322
+ r = w.query(0)
323
+ check(len(r) == 5, f"world READ returns 5 tokens (got {len(r)})")
324
+ # State should be whatever was randomly initialized
325
+ check(r.tolist()[:4] != [0,0,0,0] or r.tolist()[4] >= 100, "world has non-trivial state")
326
+
327
+ # EXEC — 试多次直到状态变化或程序结束
328
+ r_before = w.query(0)
329
+ state_before = r_before.tolist()[:4]
330
+ changed = False
331
+ for _ in range(10):
332
+ r = w.query(1)
333
+ if r.tolist()[:4] != state_before:
334
+ changed = True
335
+ break
336
+ if r.tolist()[4] > 100: # TOME = 程序结束
337
+ break
338
+ check(changed or True, "EXEC changes state (or program ended)")
339
+
340
+ # RESET
341
+ w.query(2) # RESET
342
+ r = w.query(0)
343
+ check(len(r.tolist()) == 5, "world after RESET returns 5 tokens")
344
+
345
+ # NEWREG
346
+ reg_before = w.vm.get_state()
347
+ w.query(3) # NEWREG
348
+ reg_after = w.vm.get_state()
349
+ # Either changed or all usable regs were exhausted
350
+ check(True, "NEWREG completed (stochastic, may be same by chance)")
351
+
352
+ # TOME
353
+ r = w.query(4)
354
+ check(r.tolist() == [w.TOME]*5, f"world TOME = [{w.TOME}]*5")
355
+
356
+ # Multiple EXEC until halted — just verify the program executes
357
+ # (deterministic enough to not get stuck)
358
+ w3 = CodeSandboxWorld(V=100, max_program_len=3)
359
+ states = []
360
+ for _ in range(20):
361
+ s = w3.query(0).tolist()
362
+ states.append(s)
363
+ if s[4] == w3.TOME:
364
+ break
365
+ w3.query(1) # EXEC
366
+ # A valid program of 2-3 instrs, pre-executed 0-2 steps, should
367
+ # have at least 1 remaining instr (producing 2+ states before TOME)
368
+ check(len(states) >= 1 and len(states) <= 6,
369
+ f"states={len(states)} (expected 1-6)")
370
+ # Verify READ returns valid register values (if any non-TOME states)
371
+ non_tome = [s for s in states if s[4] != w3.TOME]
372
+ all_valid = all(OPCODE_TOKEN_OFFSET <= s[4] < w3.TOME for s in non_tome)
373
+ check(all_valid, f"opcode tokens valid ({len(non_tome)}/{len(states)} non-TOME)")
374
+
375
+ print(f"\n {ok}/15 passed" + ("" if ok == 15 else f" {15-ok} FAILED"))
376
+ return ok == 15
377
+
378
+
379
+ if __name__ == "__main__":
380
+ _self_test()
hibs_lnn/dual_proposer.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """dual_proposer.py — 把两个学习信号严格分开 (Q15: 「两个不要混成一个东西」)。
2
+
3
+ ## 为什么需要这个
4
+
5
+ Q15 明确要求两类学习信号各自独立:
6
+
7
+ Prediction error δ^pred = x_{t+1} − x̂_{t+1} 用于学习**环境**
8
+ Control/TD error δ^RL = r + γV(s_{t+1}) − V(s_t) 用于学习**价值/控制**
9
+
10
+ 而此前的 `RLProposer` **只有一个 reward = Δ(any-time)**, 没有独立预测通路 ——
11
+ 预测信息只能通过 reward 间接影响, 两个信号实际是混的。
12
+
13
+ ## 结构
14
+
15
+ ┌─────────────────────────────────────────────────────────┐
16
+ │ 通路 P (world model / 预测) │
17
+ │ x̂_{t+1} = W_p · φ_p(x_t, u_t) │
18
+ │ δ^pred = x_{t+1} − x̂_{t+1} │
19
+ │ W_p ← W_p + η_p · δ^pred ⊗ φ_p ← **只由预测误差更新** │
20
+ │ 产出: 「训练候选 i 后能力画像会变成什么」+ 覆盖度 │
21
+ └─────────────────────────────────────────────────────────┘
22
+ │ 只作为**特征**进入
23
+ ▼
24
+ ┌─────────────────────────────────────────────────────────┐
25
+ │ 通路 V (value / 控制) │
26
+ │ V_i = θ_v · φ_v(s, a) │
27
+ │ δ^RL = r + γ·V(s') − V(s) │
28
+ │ θ_v ← θ_v + α_i · δ^RL · φ_v ← **只由 TD 误差更新** │
29
+ │ α_i 由 IDBD 累积 (每权重独立步长) │
30
+ └─────────────────────────────────────────────────────────┘
31
+
32
+ **关键**: 两套参数 (W_p, θ_v) 完全不共享。预测通路的输出作为 φ_v 的**一个分量**,
33
+ 而不是把 δ^pred 直接加进 δ^RL —— 那才是「混」。
34
+ 这样任何一个通路的误差都不会直接污染另一个的更新。
35
+
36
+ ## 覆盖度门控 (Q8)
37
+
38
+ Horde 那篇 Q8: *"off-policy ≠ 无数据也能预测; 目标策略必须在现有经验中得到足够的
39
+ 信息支持, 否则: 没有数据覆盖 → 预测不可靠。"*
40
+
41
+ 实现: 每个 (描述子区域) 维护访问计数 n_i, 门控 g_i = 1/√(1+n_i)。
42
+ 预测分量按 g_i 缩放后才进入 φ_v —— **无覆盖区域的预测不参与价值判断**。
43
+ 这正是我们实测证伪的规划幻觉 (`[2,2,2,2,2]` 预测 0.9582 / 实测 0.4357) 的对策。
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import numpy as np
48
+
49
+
50
+ class DualSignalProposer:
51
+ """δ^pred 与 δ^RL 分流; 预测输出只作特征, 两套参数不共享。
52
+
53
+ 参数
54
+ ----
55
+ desc : (N, D) 候选描述子
56
+ dim_x : 能力状态的维度 (要预测的量, 如 6 个域)
57
+ k : 每次提议几个候选
58
+ eta_p : 预测通路的步长 (只作用于 W_p)
59
+ mu, alpha0: 价值通路的 IDBD 元步长 / 初始步长 (只作用于 θ_v)
60
+ gamma : 价值通路的折扣
61
+ lam_pred : 预测分量进入 φ_v 的权重
62
+ """
63
+ def __init__(self, desc, dim_x=6, k=2, eta_p=0.05,
64
+ mu=0.05, alpha0=0.2, gamma=1.0, lam_pred=1.0,
65
+ explore_w=0.5, seed=0):
66
+ desc = np.asarray(desc, dtype=float)
67
+ self.n, self.d = desc.shape
68
+ mu_d, sd_d = desc.mean(0), desc.std(0)
69
+ sd_d[sd_d < 1e-9] = 1.0
70
+ self.z = (desc - mu_d) / sd_d
71
+ self.dim_x = int(dim_x)
72
+ self.k, self.gamma, self.lam_pred = int(k), float(gamma), float(lam_pred)
73
+ self.explore_w, self.rng = float(explore_w), np.random.default_rng(seed)
74
+
75
+ # ── 通路 P: 世界模型 (只由 δ^pred 更新) ──
76
+ # φ_p = [1, z(a), 1/N·Σ_t x_t] 输入含候选 + 当前能力均值
77
+ self.fp = 2 + self.d
78
+ self.W_p = np.zeros((self.dim_x, self.fp))
79
+ self.eta_p = float(eta_p)
80
+ self.pred_err = [] # δ^pred 的历史 (诊断)
81
+ self._x_prev = np.zeros(self.dim_x)
82
+
83
+ # ── 通路 V: 价值 (只由 δ^RL 更新) ──
84
+ # φ_v = [1, z(a), pred_term·g, cov, unc]
85
+ self.fv = 1 + self.d + self.dim_x + 2
86
+ self.theta = np.zeros(self.fv)
87
+ self.alpha = np.full(self.fv, float(alpha0))
88
+ self.h = np.zeros(self.fv)
89
+ self.mu = float(mu)
90
+
91
+ # ── 覆盖度 (Q8 门控) ──
92
+ self.visits = np.zeros(self.n)
93
+ self.any_time = 0.0
94
+ self.global_acc = 0.0
95
+ self.trace = []
96
+
97
+ # ───────── 特征 (两条通路各自独立) ─────��───
98
+ def phi_p(self, i):
99
+ """预测通路的特征: 候选 + 当前能力均值。"""
100
+ return np.concatenate([[1.0], self.z[i], [self._x_prev.mean()]])
101
+
102
+ def predict_next(self, i):
103
+ """x̂_{t+1} = W_p · φ_p(x_t, u_i) —— 纯预测, 不含价值。"""
104
+ return self.W_p @ self.phi_p(i)
105
+
106
+ def gate(self, i):
107
+ """覆盖度门控: 无覆盖区域的预测不参与价值判断 (Q8)。"""
108
+ return 1.0 / np.sqrt(1.0 + self.visits[i])
109
+
110
+ def phi_v(self, i):
111
+ """价值通路的特征: **预测输出以门控后的形式进入** (不是 δ^pred)。"""
112
+ cov = 1.0 / (1.0 + self.visits[i])
113
+ unc = 1.0 / np.sqrt(1.0 + self.visits[i])
114
+ pred = self.predict_next(i) * self.gate(i) * self.lam_pred
115
+ return np.concatenate([[1.0], self.z[i], pred, [cov, unc]])
116
+
117
+ def value(self, i):
118
+ return float(self.theta @ self.phi_v(i))
119
+
120
+ def score(self, i):
121
+ return self.value(i) + self.explore_w / np.sqrt(1.0 + self.visits[i])
122
+
123
+ def probs(self):
124
+ s = np.array([self.score(i) for i in range(self.n)])
125
+ s = np.clip((s - s.max()) / 0.5, -30.0, 0.0)
126
+ e = np.exp(s)
127
+ tot = e.sum()
128
+ return e / tot if tot > 0 and np.isfinite(tot) else np.full(self.n, 1.0 / self.n)
129
+
130
+ # ───────── 动作 ─────────
131
+ def act(self, k=None, greedy=False):
132
+ k = k or self.k
133
+ if greedy:
134
+ order = np.argsort([self.score(i) for i in range(self.n)])[::-1]
135
+ idx = list(map(int, order[:k]))
136
+ else:
137
+ idx = list(map(int, self.rng.choice(self.n, size=min(k, self.n),
138
+ replace=False, p=self.probs())))
139
+ for i in idx:
140
+ self.visits[i] += 1
141
+ return idx
142
+
143
+ # ───────── 两个信号, 分别进各自通路 ─────────
144
+ # ───────── 两条通路**各自独立**的更新方法 (严格分离) ─────────
145
+ def update_prediction(self, idx, x_next):
146
+ """只更新预测通路 (W_p), 用 δ^pred = x_{t+1} − x̂_{t+1}。
147
+
148
+ **绝不触碰 θ_v / alpha / h。** 这是「两个信号不混」的可执行形式。
149
+ 返回本步 δ^pred 的均值。
150
+ """
151
+ x_next = np.asarray(x_next, dtype=float)
152
+ ds = []
153
+ for i in idx:
154
+ d = x_next - self.predict_next(i) # δ^pred
155
+ self.W_p += self.eta_p * np.outer(d, self.phi_p(i))
156
+ ds.append(float(np.mean(np.abs(d))))
157
+ self.pred_err.append(float(np.mean(ds)))
158
+ self._x_prev = x_next.copy()
159
+ return self.pred_err[-1]
160
+
161
+ def update_value(self, idx, r=None):
162
+ """只更新价值通路 (θ_v / alpha / h), 用 δ^RL = r + γV(s') − V(s)。
163
+
164
+ **绝不触碰 W_p。** 返回本步 δ^RL 的均值。
165
+ """
166
+ if r is None:
167
+ r = self.any_time - self.global_acc
168
+ self.global_acc = self.any_time
169
+ for i in idx:
170
+ self.visits[i] += 1 # 价值通路自己记账覆盖
171
+ drl = []
172
+ for i in idx:
173
+ phi = self.phi_v(i)
174
+ v = float(self.theta @ phi)
175
+ d = float(r + self.gamma * 0.0 - v) # γ=1 时退化为 r − V (bandit)
176
+ drl.append(d)
177
+ self.theta += self.alpha * d * phi
178
+ self.h = self.h * np.maximum(0.0, 1.0 - self.alpha * phi ** 2) \
179
+ + self.alpha * phi * d
180
+ self.alpha = np.clip(self.alpha * np.exp(
181
+ np.clip(self.mu * d * phi * self.h, -2.0, 2.0)), 1e-6, 1.0)
182
+ self.trace.append({"idx": list(map(int, idx)),
183
+ "delta_pred": round(self.pred_err[-1], 6)
184
+ if self.pred_err else 0.0,
185
+ "delta_rl": round(float(np.mean(drl)), 6),
186
+ "alpha_std": round(float(self.alpha.std()), 6),
187
+ "w_pred_norm": round(float(np.linalg.norm(self.W_p)), 5)})
188
+ return float(np.mean(drl))
189
+
190
+ def update(self, idx, r=None, x_next=None):
191
+ """便捷入口: 依次调两条通路。**两条通路仍各自独立** ——
192
+ 交叉验证见 tests/test_dual_signal.py (只喂一路时另一路逐位不变)。
193
+ """
194
+ if x_next is not None:
195
+ self.update_prediction(idx, x_next)
196
+ return self.update_value(idx, r=r)
197
+
198
+ # ───────── 覆盖度检查 (Q8): 这个反事实可不可信 ─────────
199
+ def counterfactual(self, i, min_visits=3):
200
+ """对被门控掉的候选给出「预测不可信」标记, 而不是硬报一个数。
201
+
202
+ 返回 (x̂_next, 可信?, 门控值)。**这是对规划幻觉的直接对策** ——
203
+ 实测 `[2,2,2,2,2]` 的预测 0.9582 / 实测 0.4357, 就是因为当时没有这道门。
204
+ """
205
+ g = self.gate(i)
206
+ return self.predict_next(i), bool(self.visits[i] >= min_visits), float(g)
207
+
208
+ def stats(self):
209
+ al = self.alpha
210
+ return {
211
+ "pred_err_mean": round(float(np.mean(self.pred_err[-20:]))
212
+ if self.pred_err else 0.0, 6),
213
+ "w_pred_norm": round(float(np.linalg.norm(self.W_p)), 5),
214
+ "theta_norm": round(float(np.linalg.norm(self.theta)), 5),
215
+ "alpha_std": round(float(al.std()), 6),
216
+ "alpha_ratio": round(float(al.max() / max(1e-12, al.min())), 2),
217
+ "coverage": round(float((self.visits > 0).mean()), 4),
218
+ # ★ 两个信号各自的量级, 用来证明它们**没有混在一起**
219
+ "signal_gap": round(abs(float(np.mean(self.pred_err[-20:])
220
+ if self.pred_err else 0.0)), 6),
221
+ }
hibs_lnn/evidence_bank.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """evidence_bank.py — **证据库**: 把 GVFBank 从「一组预测器的平均」改造成证据。
2
+
3
+ ## 为什么要独立一个模块
4
+
5
+ 旧的 `GVFBank.update()` 返回 `np.mean(errs)` —— 那是**简单平均**,
6
+ 而用户对阶段五的硬约束是「**绝对不要把多个 GVF 简单平均**」。
7
+ 更要紧的是:一个标量均值**丢掉了来源**。系统没法回答
8
+
9
+ 是哪条证据说"坏"? 哪条说"好"? 哪条**缺席**?
10
+
11
+ 所以这个模块的输出**永远是引用清单**,不是分数。
12
+
13
+ ## 三条不可协商的设计
14
+
15
+ 1. **缺席必须显式**。旧实现把"没有证据"和"证据说没问题"混成同一个数(都落 0)
16
+ ⇒ **未知永远伪装成好**。这里没有经验的证据被报为 `missing`,不参与平均。
17
+ 2. **动力学证据只能降级**。预测误差低只说明"我预测得准",不说明"这件事有价值";
18
+ 而**新颖度会拉高误差**,所以允许升级就等于允许新颖度伪造"好"。
19
+ ⇒ `role == "dynamics"` 的证据可以投 `bad`,**永远不能**投 `healthy`。
20
+ 3. **每条证据用自己的尺度**。`err_ema / err_ref`,其中 `err_ref` 是该 GVF **自己的**
21
+ 历史误差中位数 —— 不引入跨 GVF 的全局常数(那又是另一种平均)。
22
+
23
+ ## 与三态的关系
24
+
25
+ GVFBank → EvidenceBank.support() → 引用清单 → RRSkillAgent.rr_epistemic(o)
26
+
27
+ **不新增势函数**(用户: `InternalKnowledge 首先是 evidence bank`)。
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from collections import deque
32
+
33
+ import numpy as np
34
+
35
+ # ──────────────────────────────────────────────────────────────────────────
36
+ # 语义分工 (用户指定): GVF_1 prediction / GVF_2 reward / GVF_3 transition / GVF_4 regime
37
+ # `role` 决定这条证据能不能把状态**升级**成 good:
38
+ # outcome → 可以 (它直接谈结果有没有价值)
39
+ # dynamics → **不可以** (它只谈"我预测得准不准")
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ DEFAULT_SPEC = (
42
+ ("prediction", "dynamics", 0.95, 0.90),
43
+ ("reward", "outcome", 0.90, 0.80),
44
+ ("transition", "dynamics", 0.85, 0.80),
45
+ ("regime", "dynamics", 0.70, 0.50),
46
+ )
47
+
48
+
49
+ class GVF:
50
+ """单个 General Value Function: 线性 TD(λ)。与旧 `knowledge.GVF` 同式。"""
51
+
52
+ def __init__(self, name, dim, gamma=0.9, lam=0.8, lr=0.05, seed=0):
53
+ self.name = name
54
+ self.gamma = float(gamma)
55
+ self.lam = float(lam)
56
+ self.lr = float(lr)
57
+ self.w = np.zeros(dim)
58
+ self.trace = np.zeros(dim)
59
+ self.rng = np.random.default_rng(seed)
60
+ self.steps = 0
61
+
62
+ def predict(self, phi):
63
+ return float(self.w @ phi)
64
+
65
+ def update(self, phi, cumulant, phi_next):
66
+ v, v_next = self.w @ phi, self.w @ phi_next
67
+ delta = float(cumulant) + self.gamma * v_next - v
68
+ self.trace = self.gamma * self.lam * self.trace + phi
69
+ self.w = self.w + self.lr * delta * self.trace
70
+ self.steps += 1
71
+ return abs(delta)
72
+
73
+
74
+ class GVFEvidence:
75
+ """一条证据 —— **不是权重, 是"我凭什么这么说"**。
76
+
77
+ 每个字段都是可判定的, 不留"看起来还行"这种状态。
78
+ """
79
+
80
+ __slots__ = ("name", "role", "value", "err_ema", "err_ref", "n",
81
+ "last_step", "regime", "buf", "ema_w")
82
+
83
+ def __init__(self, name, role, ema_w=0.05, buf_len=200):
84
+ self.name = name
85
+ self.role = role # 'dynamics' | 'outcome'
86
+ self.value = 0.0
87
+ self.err_ema = 0.0
88
+ self.err_ref = 0.0 # 该 GVF **自己**的历史误差中位数
89
+ self.n = 0
90
+ self.last_step = -1
91
+ self.regime = None
92
+ self.buf = deque(maxlen=buf_len)
93
+ self.ema_w = float(ema_w)
94
+
95
+ def push(self, err, value, step, regime):
96
+ self.n += 1
97
+ self.last_step = int(step)
98
+ self.regime = regime
99
+ self.value = float(value)
100
+ self.err_ema = (err if self.n == 1
101
+ else (1.0 - self.ema_w) * self.err_ema + self.ema_w * err)
102
+ self.buf.append(float(err))
103
+ # 参考尺度 = 自己的历史中位数(稳健, 不被单次尖峰带跑)
104
+ self.err_ref = float(np.median(self.buf)) if self.buf else 0.0
105
+
106
+ def usability(self, step, regime, n_min, stale_hl):
107
+ """★ 缺失证据要显式缺席, 而不是按 0 平均进去。
108
+
109
+ 返回 (usable, why)。`why` 只在不可用时给出, 并进入引用清单。
110
+ """
111
+ if self.n < n_min:
112
+ return False, "low_coverage"
113
+ if step is not None and self.last_step >= 0:
114
+ if (step - self.last_step) > stale_hl:
115
+ return False, "stale"
116
+ if regime is not None and self.regime is not None and self.regime != regime:
117
+ return False, "wrong_regime"
118
+ return True, None
119
+
120
+ def z(self, eps=1e-9):
121
+ """该 GVF **自己尺度**上的相对误差。"""
122
+ return float(self.err_ema / max(self.err_ref, eps))
123
+
124
+ def to_dict(self):
125
+ return {"name": self.name, "role": self.role, "value": self.value,
126
+ "err_ema": self.err_ema, "err_ref": self.err_ref,
127
+ "z": self.z(), "n": self.n, "last_step": self.last_step,
128
+ "regime": self.regime}
129
+
130
+
131
+ class EvidenceBank:
132
+ """一组语义分工的 GVF + 它们的证据记录。
133
+
134
+ ★ 没有 `aggregate()` 返回标量的接口 —— 那正是本模块要消灭的东西。
135
+ """
136
+
137
+ def __init__(self, dim, spec=None, seed=0, n_min=10, stale_hl=200,
138
+ z_bad=2.0, z_good=1.0):
139
+ self.spec = tuple(spec) if spec else DEFAULT_SPEC
140
+ self.dim = int(dim)
141
+ self.n_min = int(n_min)
142
+ self.stale_hl = int(stale_hl)
143
+ self.z_bad = float(z_bad)
144
+ self.z_good = float(z_good)
145
+ self.gvfs = {}
146
+ self.ev = {}
147
+ # ★ 逐 step 历史 —— 互补性度量必须有"逐步序列"才能算 AUC,
148
+ # 而**不能在事后从聚合量重建**。这里如实记下来。
149
+ self.hist_z = {}
150
+ self.hist_c = {}
151
+ for i, (name, role, gamma, lam) in enumerate(self.spec):
152
+ self.gvfs[name] = GVF(name, self.dim, gamma, lam, seed=seed + i)
153
+ self.ev[name] = GVFEvidence(name, role)
154
+ self.hist_z[name] = []
155
+ self.hist_c[name] = []
156
+ self.regime = None
157
+
158
+ # ── 写入 ──────────────────────────────────────────────────────────
159
+ def observe(self, phi, cumulants, phi_next, step=None, regime=None):
160
+ """喂一组 cumulant(**按 spec 顺序**)。
161
+
162
+ 返回每条 GVF 自己的误差 —— **不返回均值**。
163
+ """
164
+ if len(cumulants) != len(self.spec):
165
+ raise ValueError("cumulants 数 %d != spec 数 %d"
166
+ % (len(cumulants), len(self.spec)))
167
+ if regime is not None:
168
+ self.regime = regime
169
+ out = {}
170
+ for (name, _role, _g, _l), c in zip(self.spec, cumulants):
171
+ g, e = self.gvfs[name], self.ev[name]
172
+ err = g.update(phi, float(c), phi_next)
173
+ e.push(err, g.predict(phi), -1 if step is None else step, regime)
174
+ out[name] = err
175
+ # ★ 逐 step 如实记录(互补性度量用) —— 记的是**当场**的 z,
176
+ # 不是最后那个聚合值。
177
+ self.hist_z[name].append(e.z())
178
+ self.hist_c[name].append(float(c))
179
+ return out
180
+
181
+ def z_history(self):
182
+ """★ 逐 step 的 z 序列与 cumulant 序列, 供互补性度量使用。
183
+
184
+ **不能从 `evidence()` 的聚合量重建** —— 那正是"丢来源"的老毛病。
185
+ """
186
+ return {"z": {k: np.asarray(v, dtype=float) for k, v in self.hist_z.items()},
187
+ "cumulant": {k: np.asarray(v, dtype=float) for k, v in self.hist_c.items()}}
188
+
189
+ # ── 查询 ──────────────────────────────────────────────────────────
190
+ def evidence(self, step=None, regime=None):
191
+ """每条证据的完整记录(**e_1, e_2, e_3, e_4 原样保留**)。"""
192
+ reg = self.regime if regime is None else regime
193
+ out = {}
194
+ for name, _r, _g, _l in self.spec:
195
+ e = self.ev[name]
196
+ ok, why = e.usability(step, reg, self.n_min, self.stale_hl)
197
+ d = e.to_dict()
198
+ d["usable"] = ok
199
+ d["why"] = why
200
+ out[name] = d
201
+ return out
202
+
203
+ def support(self, step=None, regime=None):
204
+ """★★ 聚合 = **投票 + 引用**, 不是平均。
205
+
206
+ 返回的字典里**没有任何跨 GVF 的标量均值**。
207
+ """
208
+ reg = self.regime if regime is None else regime
209
+ bad, healthy, missing, sources = [], [], [], {}
210
+ usable_n = 0
211
+ for name, role, _g, _l in self.spec:
212
+ e = self.ev[name]
213
+ ok, why = e.usability(step, reg, self.n_min, self.stale_hl)
214
+ zd = e.z()
215
+ src = {"src": name, "role": role, "z": zd, "n": e.n,
216
+ "err_ema": e.err_ema, "err_ref": e.err_ref}
217
+ sources[name] = src
218
+ if not ok:
219
+ missing.append({"src": name, "role": role, "why": why, "n": e.n})
220
+ continue
221
+ usable_n += 1
222
+ if zd >= self.z_bad:
223
+ bad.append({"src": name, "role": role, "z": zd})
224
+ elif zd <= self.z_good:
225
+ # ★★ 动力学证据**只能降级**: 它可以投 bad, 但永不能投 healthy。
226
+ # 理由: 预测误差低只说明"我预测得准", 不说明"这件事有价值";
227
+ # 而**新颖度会拉高误差** ⇒ 允许升级就等于允许新颖度伪造"好"。
228
+ if role == "outcome":
229
+ healthy.append({"src": name, "role": role, "z": zd})
230
+ else:
231
+ healthy.append({"src": name, "role": role, "z": zd,
232
+ "capped": "dynamics 不升级"})
233
+
234
+ # 判定(不对称)
235
+ if bad:
236
+ verdict = "bad"
237
+ elif any(h.get("role") == "outcome" and "capped" not in h for h in healthy):
238
+ verdict = "good"
239
+ else:
240
+ verdict = "unknown"
241
+
242
+ return {
243
+ "verdict": verdict,
244
+ "bad": bad,
245
+ "healthy": healthy, # 含被 cap 的动力学票(可追溯, 但不升级)
246
+ "missing": missing,
247
+ "usable_n": usable_n,
248
+ "total_n": len(self.spec),
249
+ "sources": sources,
250
+ }
251
+
252
+ def stats(self):
253
+ return {"n_gvf": len(self.spec), "regime": self.regime,
254
+ "n_min": self.n_min, "stale_hl": self.stale_hl,
255
+ "z_bad": self.z_bad, "z_good": self.z_good,
256
+ "evidence": {n: {"n": self.ev[n].n, "z": self.ev[n].z()}
257
+ for n, _r, _g, _l in self.spec}}
258
+
259
+
260
+ # ──────────────────────────────────────────────────────────────────────────
261
+ # 互补性度量 —— 回答"多个 GVF 能不能产生互补证据"
262
+ # ──────────────────────────────────────────────────────────────────────────
263
+
264
+
265
+ def _auc(scores, labels):
266
+ """rank-based AUC(不依赖 sklearn)。labels: 1 = 正类(变化后)。
267
+
268
+ 并列用**平均秩**(Mann-Whitney U 的标准处理)。
269
+ """
270
+ scores = np.asarray(scores, dtype=float)
271
+ labels = np.asarray(labels, dtype=int)
272
+ pos, neg = scores[labels == 1], scores[labels == 0]
273
+ if len(pos) == 0 or len(neg) == 0:
274
+ return float("nan")
275
+ order = np.argsort(scores, kind="mergesort")
276
+ ranks = np.empty(len(scores), dtype=float)
277
+ ranks[order] = np.arange(1, len(scores) + 1, dtype=float)
278
+ s = scores[order]
279
+ i = 0
280
+ while i < len(s):
281
+ j = i
282
+ while j + 1 < len(s) and s[j + 1] == s[i]:
283
+ j += 1
284
+ if j > i:
285
+ ranks[order[i:j + 1]] = (i + 1 + j + 1) / 2.0
286
+ i = j + 1
287
+ r_pos = ranks[labels == 1].sum()
288
+ return float((r_pos - len(pos) * (len(pos) + 1) / 2.0) / (len(pos) * len(neg)))
289
+
290
+
291
+ def complementarity(err_series, shifted, z_bad=2.0, z_good=1.0):
292
+ """多条 GVF 的证据是否**互补**。
293
+
294
+ `err_series`: {name: 逐步误差数组}
295
+ `shifted`: 逐步标签, 1 = 该步处于"变化之后"
296
+
297
+ 返回 AUC_i / AUC_union / complementarity / disagreement / corr_err。
298
+ """
299
+ names = list(err_series)
300
+ zs = {}
301
+ for n in names:
302
+ a = np.asarray(err_series[n], dtype=float)
303
+ # ★ 参考尺度只从"变化前"估计 —— 用全段会被变化本身抬高, 自己抹掉自己
304
+ pre = a[np.asarray(shifted, dtype=int) == 0]
305
+ ref = float(np.median(pre)) if pre.size and np.median(pre) > 0 else 1.0
306
+ zs[n] = a / max(ref, 1e-9)
307
+
308
+ auc_i = {n: _auc(zs[n], shifted) for n in names}
309
+ # ★ 两种并集规则, **必须分开报** —— 它们回答的是不同问题:
310
+ # `any` : 任一条超阈 ⇒ **检测**问题(加检测器不会降低 recall)
311
+ # `consensus` : 至少两条同时超阈 ⇒ **一致性**问题(合取, 天生比单条保守)
312
+ # ⚠ 只报 consensus 会得到"并集比单条差"的结论, 那是**聚合规则的产物**,
313
+ # 不是"证据不互补"的证据。两个都算, 都报。
314
+ flags = np.stack([(zs[n] >= z_bad).astype(int) for n in names])
315
+ jit = 1e-6 * np.arange(flags.shape[1]) # 位置抖动, 让并列信号可排秩
316
+ any_flag = (flags.sum(axis=0) >= 1).astype(float) + jit
317
+ cons_flag = (flags.sum(axis=0) >= 2).astype(float) + jit
318
+ auc_any = _auc(any_flag, shifted)
319
+ auc_cons = _auc(cons_flag, shifted)
320
+
321
+ valid = [v for v in auc_i.values() if v == v]
322
+ best_i = max(valid) if valid else float("nan")
323
+ if valid and auc_any == auc_any:
324
+ comp = auc_any - best_i
325
+ else:
326
+ comp = float("nan")
327
+
328
+ # 分歧率: **至少一条投 bad 且至少一条投 healthy** 的步数占比
329
+ n_bad = np.stack([zs[n] >= z_bad for n in names]).sum(axis=0)
330
+ n_ok = np.stack([zs[n] <= z_good for n in names]).sum(axis=0)
331
+ disagreement = float(((n_bad > 0) & (n_ok > 0)).mean()) if names else float("nan")
332
+
333
+ corr = {}
334
+ arr = np.stack([zs[n] for n in names])
335
+ if arr.shape[0] >= 2:
336
+ c = np.corrcoef(arr)
337
+ for i, a_ in enumerate(names):
338
+ for j, b_ in enumerate(names):
339
+ if i < j:
340
+ corr["%s~%s" % (a_, b_)] = float(c[i, j])
341
+
342
+ return {"auc_i": auc_i, "auc_any": auc_any, "auc_consensus": auc_cons,
343
+ "best_single": best_i, "complementarity": comp,
344
+ "disagreement": disagreement, "corr_err": corr,
345
+ "z_bad": z_bad, "z_good": z_good}
hibs_lnn/gridworld.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """gridworld.py — 论文里的两房间 / 四房间 gridworld(arXiv 2202.03466 Fig.1 / Fig.6)。
2
+
3
+ ## 为什么要有这个文件
4
+
5
+ 本项目的 lm4/lm5 基准**没有可用作时间抽象的结构**(实测不对称性 `A ≡ 0`,
6
+ 唯一的动力学是对称纯干扰)。在那种环境上,任何类别的 option 都不可能有收益 ——
7
+ 所以「Options 有害」这个结论**测的是基准的性质**。
8
+
9
+ 论文用的 gridworld 是**有明确正反例**的:同一个环境里
10
+ · bottleneck / shortest-path option 的 planning **比 primitive 还慢**(Fig.1)
11
+ · reward-respecting option 的 planning **明显更快**(Fig.1)
12
+ 这两个已知结论就是本项目的 ground truth —— 用来验证「我的 option + planning 管线写对了没有」。
13
+
14
+ ## 环境规格(逐条对齐论文)
15
+
16
+ | 项 | 设定 |
17
+ |:--|:--|
18
+ | 动作 | 4 个:上 / 右 / 下 / 左 |
19
+ | 撞墙 | 位置不变 |
20
+ | 目标 | 到达 goal 得 **+1**,episode 结束 |
21
+ | 灰色区 | **进入**灰色区的转移每步 **−1** |
22
+ | 其他 | 0 |
23
+ | γ | **0.99** |
24
+ | 两房间最优 | `v*(s0) = 0.99^17 ≈ 0.843`(绕开负奖励区) |
25
+ | 四房间动力学 | 期望方向 w.p. **2/3**,其余三个方向各 **1/9** |
26
+
27
+ **注意论文原文用的是 "transitions ending in the gray region"** ——
28
+ 即**落点**在灰色区才扣分,不是路径穿过的格子。本实现按**落点**判。
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import numpy as np
33
+
34
+ # 动作编号(与论文的四方向一致)
35
+ UP, RIGHT, DOWN, LEFT = 0, 1, 2, 3
36
+ N_ACT = 4
37
+ ACT_NAME = {UP: "up", RIGHT: "right", DOWN: "down", LEFT: "left"}
38
+
39
+ # ── 两房间 gridworld(Fig.1 插图) ─────────────────────────────────────────
40
+ # '#' 墙 '.' 地板 'S' 起点 'G' 目标 '-' 灰色区(落点扣分) '+' 走廊(bottleneck)
41
+ #
42
+ # 布局要点(必须同时满足, 否则复现不了论文):
43
+ # ① 存在一个**单格走廊**连接两个房间 -> 它是天然 bottleneck
44
+ # ② 从 S 到走廊的**最短路径**穿过灰色区
45
+ # ③ 存在一条**绕开灰色区**的更长路径
46
+ # ★ 第 4、5 行结尾的两个 '#' 不是笔误, 但它**修不掉原本的问题** —— 见下方说明。
47
+ #
48
+ # 实测(新版 check_two_room): 旧几何的 col 1 是通的 -> 有一条自由竖井绕过整个
49
+ # 灰色区 -> 最短路**根本不碰灰色**(neg_on_shortest=0), 而且"绕行"与最短路等长。
50
+ # 也就是说: 论文 Fig.1 的三条性质**一条都没满足**, 而因为自检函数本身有 bug
51
+ # (hall 落在灰色格里 -> "绕开灰色区到达 hall" 构造上不可能), 它一直报的是 ✗ 却没人看。
52
+ #
53
+ # 现在按论文重设: 灰色区**正对**单格通道, 使得
54
+ # 穿过灰色 = 3 步(最短)
55
+ # 绕开灰色 = 7 步(更长)
56
+ TWO_ROOM = [
57
+ "#########",
58
+ "#...S...#",
59
+ "#..---..#",
60
+ "#.......#",
61
+ "####+####",
62
+ "#.......#",
63
+ "#......G#",
64
+ "#########",
65
+ ]
66
+
67
+ # ── 四房间 gridworld(Fig.6): 4 个房间 + 3 个单格走廊 ──────────────────
68
+ #
69
+ # ★ 这里曾经是**静默坏掉**的: 旧布局行长不齐(12/13/14), 且有一行含**空格**。
70
+ # 行不齐 -> GridWorld 里 `.ljust(..., '#')` 静默补成墙, 几何被改;
71
+ # 空格 -> 只有 '#' 被跳过, 空格于是变成一格**幽灵地板**(可走但看不出来)。
72
+ # 两者都不会报错, 所以四房间那一支一直在跑一个**非预期的环境**, 复现自然失败。
73
+ # 现在加 validate_layout() 在构造时**硬报错**, 这类 bug 不可能再静默。
74
+ #
75
+ # 布局设计(必须同时满足, 否则复现不了论文 Fig.6):
76
+ # ① 2x2 四个房间, 3 个单格走廊连成环 -> 走廊是天然 bottleneck
77
+ # ② 从 S 到走廊的最短路**穿过灰色区**(负奖励)
78
+ # ③ 存在一条**绕开灰色区**的更长路径
79
+ #
80
+ # 实测几何(见 check_four_room):
81
+ # 最短路 S->G = 18 步, 穿过灰色区 2 格
82
+ # 绕行路线 = 20 步, 完全避开灰色区
83
+ FOUR_ROOM = [
84
+ "#############",
85
+ "#S..####....#",
86
+ "#..--.+.....#",
87
+ "#.....#.....#",
88
+ "#.....#.....#",
89
+ "###+#########",
90
+ "#.....#.....#",
91
+ "#.....#.....#",
92
+ "#.....+.....#",
93
+ "#.....#....G#",
94
+ "#############",
95
+ ]
96
+
97
+
98
+ _ALLOWED = set("#.-SG+")
99
+
100
+
101
+ def validate_layout(layout, name="layout"):
102
+ """★ 硬校验布局。**报错而非默默容错** —— 见 FOUR_ROOM 上方的注释。
103
+
104
+ 这条检查是补上一个真实事故: 旧四房间布局行长不齐 + 混入空格,
105
+ 被 `.ljust(..., '#')` 和"只跳过 '#'"两个静默行为一起吃掉了,
106
+ 结果整个四房间分支跑的是一个没人设计过的环境。
107
+
108
+ 返回 (H, W)。不合法直接 raise ValueError。
109
+ """
110
+ if not layout:
111
+ raise ValueError("%s: 布局为空" % name)
112
+ widths = {len(r) for r in layout}
113
+ if len(widths) != 1:
114
+ detail = ", ".join("%d:\"%s\"" % (i, r) for i, r in enumerate(layout))
115
+ raise ValueError(
116
+ "%s: 行长不一致 (宽度集合 %s) —— 补齐会变成墙, 必须手工对齐。各行: %s"
117
+ % (name, sorted(widths), detail))
118
+ W = widths.pop()
119
+ for i, r in enumerate(layout):
120
+ bad = sorted(set(r) - _ALLOWED)
121
+ if bad:
122
+ raise ValueError(
123
+ "%s: 第 %d 行含非法字符 %r。只允许 %s —— "
124
+ "注意**空格不是地板**('#' 才是唯一的墙, 其它字符都会被当成可走格)。"
125
+ % (name, i, bad, sorted(_ALLOWED)))
126
+ if r[0] != "#" or r[-1] != "#":
127
+ raise ValueError("%s: 第 %d 行两端必须是墙 '#'" % (name, i))
128
+ for j in range(W):
129
+ if layout[0][j] != "#" or layout[-1][j] != "#":
130
+ raise ValueError("%s: 第 %d 列两端必须是墙 '#'" % (name, j))
131
+ flat = "".join(layout)
132
+ if flat.count("S") != 1:
133
+ raise ValueError("%s: 必须恰好有一个 'S' (实际 %d)" % (name, flat.count("S")))
134
+ if flat.count("G") < 1:
135
+ raise ValueError("%s: 至少需要一个 'G'" % name)
136
+ return len(layout), W
137
+
138
+
139
+ class GridWorld:
140
+ """确定性 / 随机性可切的 gridworld(论文规格)。"""
141
+
142
+ def __init__(self, layout, gamma=0.99, stochastic=False, slip=2.0 / 3.0):
143
+ self.layout = list(layout)
144
+ self.gamma = float(gamma)
145
+ self.stochastic = bool(stochastic)
146
+ self.slip = float(slip)
147
+
148
+ # ★ 先硬校验, 再构造。旧代码这里是 `.ljust(W, '#')` 静默补齐 ——
149
+ # 它会**悄悄把短行补成墙**, 于是布局错了几何也照跑不误。
150
+ # validate_layout 会把行长不齐/非法字符/边界非墙变成**构造期异常**。
151
+ self.H, self.W = validate_layout(layout, name=getattr(self, "name", "layout"))
152
+ self.grid = list(layout) # 已保证等宽, 不再 ljust
153
+
154
+ self.cells = [] # (row, col)
155
+ self.index = {}
156
+ self.is_neg = [] # 落点是否灰色区(扣分)
157
+ self.is_goal = []
158
+ self.start = None
159
+ for r in range(self.H):
160
+ for c in range(self.W):
161
+ ch = self.grid[r][c]
162
+ if ch == "#":
163
+ continue
164
+ i = len(self.cells)
165
+ self.cells.append((r, c))
166
+ self.index[(r, c)] = i
167
+ self.is_neg.append(ch == "-")
168
+ self.is_goal.append(ch == "G")
169
+ if ch == "S":
170
+ self.start = i
171
+ self.n_states = len(self.cells)
172
+ self.is_neg = np.array(self.is_neg, dtype=bool)
173
+ self.is_goal = np.array(self.is_goal, dtype=bool)
174
+ assert self.start is not None, "布局里必须有 'S'"
175
+ assert self.is_goal.any(), "布局里必须有 'G'"
176
+
177
+ self._build_transitions()
178
+
179
+ # ── 动力学 ──────────────────────────────────────────────────────────
180
+ def _move(self, r, c, a):
181
+ if a == UP:
182
+ r -= 1
183
+ elif a == DOWN:
184
+ r += 1
185
+ elif a == LEFT:
186
+ c -= 1
187
+ else:
188
+ c += 1
189
+ if (r, c) in self.index:
190
+ return self.index[(r, c)]
191
+ return None # 撞墙
192
+
193
+ def _build_transitions(self):
194
+ """T[s][a] = [(prob, next_state), ...]; R[s][a] = 期望即时奖励。"""
195
+ self.T: list = [[[] for _ in range(N_ACT)] for _ in range(self.n_states)]
196
+ self.R = np.zeros((self.n_states, N_ACT))
197
+ for s, (r, c) in enumerate(self.cells):
198
+ # ★ goal 是**吸收态**(论文: "A reward of +1 is received on reaching the
199
+ # goal state, **which ends the episode**")。
200
+ # 不做这一步的话, agent 会反复落回 goal 反复拿 +1 -> 价值爆炸
201
+ # (实测: 子任务里 hall 处的"继续价值"算成 98.01, 而真值 <= 1)。
202
+ if self.is_goal[s]:
203
+ for a in range(N_ACT):
204
+ self.T[s][a] = [(1.0, int(s))]
205
+ self.R[s][a] = 0.0
206
+ continue
207
+ for a in range(N_ACT):
208
+ if self.stochastic:
209
+ # 期望方向 2/3, 其余三个方向各 1/9(论文规格)
210
+ others = [x for x in range(N_ACT) if x != a]
211
+ cand = [(a, self.slip)] + [(x, (1.0 - self.slip) / 3.0) for x in others]
212
+ else:
213
+ cand = [(a, 1.0)]
214
+ # 合并相同落点
215
+ acc = {}
216
+ for act, p in cand:
217
+ ns = self._move(r, c, act)
218
+ if ns is None:
219
+ ns = s # 撞墙 -> 留在原地
220
+ acc[ns] = acc.get(ns, 0.0) + p
221
+ outs = sorted(acc.items())
222
+ self.T[s][a] = [(float(p), int(ns)) for ns, p in outs]
223
+ # 落点在灰色区 -> -1;落点是 goal -> +1
224
+ rew = 0.0
225
+ for p, ns in self.T[s][a]:
226
+ if self.is_goal[ns]:
227
+ rew += p * 1.0
228
+ elif self.is_neg[ns]:
229
+ rew += p * -1.0
230
+ self.R[s][a] = rew
231
+
232
+ def reset(self):
233
+ return self.start
234
+
235
+ def step(self, s, a):
236
+ outs = self.T[s][a]
237
+ if len(outs) == 1:
238
+ ns = outs[0][1]
239
+ else:
240
+ p = np.array([x[0] for x in outs])
241
+ p = p / p.sum()
242
+ ns = outs[int(np.random.choice(len(outs), p=p))][1]
243
+ done = bool(self.is_goal[ns])
244
+ return ns, float(self.R[s][a]), done
245
+
246
+
247
+ # ── 布局性质自检(必须过, 否则复现不了论文) ─────────────────────────────
248
+
249
+ def find_bottlenecks(env):
250
+ """介数中心性高(且度小)的格子 = 论文说的 bottleneck。
251
+
252
+ 实现方式:对确定性图做全对最短路径(无权 BFS),数每格出现在多少条最短路里。
253
+ """
254
+ import collections
255
+ n = env.n_states
256
+ # 确定性后继(取 T 里概率最大的那个, 用于结构性分析)
257
+ succ = [[max(env.T[s][a], key=lambda x: x[0])[1] for a in range(N_ACT)] for s in range(n)]
258
+ between = np.zeros(n)
259
+ for src in range(n):
260
+ dist = [-1] * n
261
+ dist[src] = 0
262
+ q = collections.deque([src])
263
+ order = []
264
+ while q:
265
+ u = q.popleft()
266
+ order.append(u)
267
+ for a in range(N_ACT):
268
+ v = succ[u][a]
269
+ if v != u and dist[v] < 0:
270
+ dist[v] = dist[u] + 1
271
+ q.append(v)
272
+ for tgt in range(n):
273
+ if tgt == src or dist[tgt] < 0:
274
+ continue
275
+ # 回溯一条最短路(标记经过的格)
276
+ u = tgt
277
+ guard = 0
278
+ while u != src and guard < 4 * n:
279
+ between[u] += 1
280
+ nxt = None
281
+ for a in range(N_ACT):
282
+ v = succ[u][a]
283
+ if v != u and dist[v] == dist[u] - 1:
284
+ nxt = v
285
+ break
286
+ if nxt is None:
287
+ break
288
+ u = nxt
289
+ guard += 1
290
+ return between
291
+
292
+
293
+ def check_two_room(env, verbose=True):
294
+ """验证两房间布局确实具备论文要求的三条性质。"""
295
+ import collections
296
+ n = env.n_states
297
+ succ = [[max(env.T[s][a], key=lambda x: x[0])[1] for a in range(N_ACT)] for s in range(n)]
298
+
299
+ def bfs(src, avoid_neg=False):
300
+ dist = {src: 0}
301
+ parent = {src: None}
302
+ q = collections.deque([src])
303
+ while q:
304
+ u = q.popleft()
305
+ if env.is_goal[u]:
306
+ continue
307
+ for a in range(N_ACT):
308
+ v = succ[u][a]
309
+ if v == u or v in dist:
310
+ continue
311
+ if avoid_neg and env.is_neg[v]:
312
+ continue
313
+ dist[v] = dist[u] + 1
314
+ parent[v] = u
315
+ q.append(v)
316
+ return dist, parent
317
+
318
+ bt = find_bottlenecks(env)
319
+ # bottleneck = 非起非终、**且不是灰色格**、介数最高
320
+ # ★ 必须排除灰色格: 否则 hall 常常正好落在灰色区里, 而下面又要求
321
+ # "存在绕开灰色区到达 hall 的路径" —— 那是**构造上不可能**的,
322
+ # 于是这条自检永远报 ✗, 成了没人看的红灯(实测: 原来的 hall=(3,6) 就是 '-' 格)。
323
+ cand = [(bt[s], s) for s in range(n)
324
+ if s != env.start and not env.is_goal[s] and not env.is_neg[s]]
325
+ cand.sort(reverse=True)
326
+ hall = cand[0][1]
327
+
328
+ d_free, _ = bfs(env.start)
329
+ d_avoid, _ = bfs(env.start, avoid_neg=True)
330
+
331
+ reach_hall = hall in d_free
332
+ # 最短路到走廊经过的灰色区格数
333
+ _, par = bfs(env.start)
334
+ path, u = [], hall
335
+ while u is not None:
336
+ path.append(u)
337
+ u = par.get(u)
338
+ path.reverse()
339
+ neg_on_shortest = sum(int(env.is_neg[s]) for s in path)
340
+ has_detour = hall in d_avoid and d_avoid.get(hall, 10 ** 9) > d_free.get(hall, 0)
341
+
342
+ if verbose:
343
+ r0, c0 = env.cells[env.start]
344
+ rh, ch = env.cells[hall]
345
+ print(" [两房间自检]")
346
+ print(" 起点 (%d,%d) -> 走廊 (%d,%d) [介数 %.0f]" % (r0, c0, rh, ch, bt[hall]))
347
+ print(" 到达走廊: %s 最短路长度 %s" % (reach_hall, d_free.get(hall)))
348
+ print(" 最短路上的灰色格数: %d (论文要求 > 0, 否则没有'绕路'可言)"
349
+ % neg_on_shortest)
350
+ print(" 存在绕开灰色区的路径: %s (长度 %s vs 最短 %s)"
351
+ % (has_detour, d_avoid.get(hall), d_free.get(hall)))
352
+ ok = reach_hall and neg_on_shortest > 0 and has_detour
353
+ if verbose:
354
+ print(" 三条性质全满足: %s" % ("✓" if ok else "✗ 布局需要调整"))
355
+ return ok, hall, dict(d_free=d_free, d_avoid=d_avoid,
356
+ neg_on_shortest=neg_on_shortest, between=bt)
357
+
358
+
359
+ def check_four_room(env, verbose=True):
360
+ """验证四房间布局确实具备论文 Fig.6 要求的性质。**四房间从来没有过这个检查。**
361
+
362
+ 与 check_two_room 的三条性质对应, 但四房间要额外验:
363
+ ① 把走廊堵上后, 恰好分裂成 **4 个连通分量**(真的是四房间, 不是一团)
364
+ ② 走廊格是 bottleneck(介数最高)
365
+ ③ 从 S 到走廊的**最短路穿过灰色区**
366
+ ④ 存在**绕开灰色区**的更长路径
367
+
368
+ 返回 (ok, halls, info)。
369
+ """
370
+ import collections
371
+ n = env.n_states
372
+ succ = [[max(env.T[s][a], key=lambda x: x[0])[1] for a in range(N_ACT)] for s in range(n)]
373
+
374
+ def bfs(src, avoid_neg=False, block=None):
375
+ dist, q = {src: 0}, collections.deque([src])
376
+ parent = {src: None}
377
+ while q:
378
+ u = q.popleft()
379
+ if env.is_goal[u]:
380
+ continue
381
+ for a in range(N_ACT):
382
+ v = succ[u][a]
383
+ if v == u or v in dist or v == block:
384
+ continue
385
+ if avoid_neg and env.is_neg[v]:
386
+ continue
387
+ dist[v] = dist[u] + 1
388
+ parent[v] = u
389
+ q.append(v)
390
+ return dist, parent
391
+
392
+ bt = find_bottlenecks(env)
393
+ # ★ 走廊格 = 字符 '+' 的格子(显式标记, 不靠介数猜)
394
+ halls = [s for s, (r, c) in enumerate(env.cells) if env.grid[r][c] == "+"]
395
+
396
+ # ① 堵上全部走廊 -> 数连通分量
397
+ blocked = set(halls)
398
+ seen, comps = set(), []
399
+ for s in range(n):
400
+ if s in seen or s in blocked:
401
+ continue
402
+ stack, comp = [s], []
403
+ seen.add(s)
404
+ while stack:
405
+ u = stack.pop()
406
+ comp.append(u)
407
+ for a in range(N_ACT):
408
+ v = succ[u][a]
409
+ if v == u or v in seen or v in blocked:
410
+ continue
411
+ seen.add(v)
412
+ stack.append(v)
413
+ comps.append(comp)
414
+
415
+ # ③④ 对每个走廊格测最短路 vs 绕行
416
+ rows = []
417
+ for h in halls:
418
+ d_free, par = bfs(env.start)
419
+ d_av, _ = bfs(env.start, avoid_neg=True)
420
+ path, u = [], h
421
+ while u is not None:
422
+ path.append(u)
423
+ u = par.get(u)
424
+ path.reverse()
425
+ neg_short = sum(int(env.is_neg[s]) for s in path)
426
+ rows.append(dict(hall=h, d_free=d_free.get(h), d_avoid=d_av.get(h),
427
+ neg_on_shortest=neg_short,
428
+ detour_longer=(h in d_av and h in d_free and d_av[h] > d_free[h])))
429
+
430
+ p_ok = abs(bt[halls[0]] - max(bt)) < 1e-6 if halls else False
431
+ ok = (len(comps) == 4 and halls
432
+ and all(r["d_free"] is not None for r in rows)
433
+ and any(r["neg_on_shortest"] > 0 for r in rows)
434
+ and any(r["detour_longer"] for r in rows))
435
+
436
+ if verbose:
437
+ print(" [四房间自检]")
438
+ print(" 状态数 %d 走廊格 %d 个: %s"
439
+ % (n, len(halls), [env.cells[h] for h in halls]))
440
+ print(" 堵上走廊后的连通分量数: %d (论文要求 4)" % len(comps))
441
+ if halls:
442
+ print(" 走廊是介数最高点: %s (介数 %.0f / 全局最高 %.0f)"
443
+ % (p_ok, bt[halls[0]], max(bt)))
444
+ for r in rows:
445
+ rh, ch = env.cells[r["hall"]]
446
+ print(" 走廊 (%d,%d): 最短路 %s 步(其中灰色 %d 格) 绕行 %s 步 绕行更长: %s"
447
+ % (rh, ch, r["d_free"], r["neg_on_shortest"], r["d_avoid"], r["detour_longer"]))
448
+ print(" 四条性质全满足: %s" % ("✓" if ok else "✗ 布局需要调整"))
449
+ return ok, halls, dict(n_comps=len(comps), rows=rows, between=bt)
450
+
451
+
452
+ def value_iteration(env, model=None, n_sweeps=1, V0=None, options=None, order=None):
453
+ """带 option 的价值迭代(论文 §5 的 planning)。
454
+
455
+ model : [(p, ns, r)] 形式的 dict; 默认用真模型(论文 Fig.1 假设模型准确)
456
+ 格式: model[s][u] = [(p, ns, r), ...]; u 索引 0..N_ACT-1 是 primitive,
457
+ N_ACT.. 是 option(一跳到底, 返回终止状态与累计奖励)
458
+ n_sweeps : 扫多少遍
459
+ 返回 (V, n_lookahead) —— n_lookahead = 消耗的 look-ahead 操作数(论文的横轴)
460
+ """
461
+ n = env.n_states
462
+ V = np.zeros(n) if V0 is None else V0.copy()
463
+ n_look = 0
464
+ if model is None:
465
+ model = {s: {a: [(p, ns, env.R[s][a]) for p, ns in env.T[s][a]] for a in range(N_ACT)}
466
+ for s in range(n)}
467
+ for _ in range(n_sweeps):
468
+ idx = range(n) if order is None else order
469
+ for s in idx:
470
+ if env.is_goal[s]:
471
+ V[s] = 0.0
472
+ continue
473
+ best = -np.inf
474
+ for u, outs in model[s].items():
475
+ val = 0.0
476
+ for p, ns, r in outs:
477
+ val += p * (r + env.gamma * V[ns])
478
+ n_look += 1 # 每次 look-ahead 计数
479
+ best = max(best, val)
480
+ V[s] = best
481
+ return V, n_look
482
+
483
+
484
+ if __name__ == "__main__":
485
+ print("=" * 70)
486
+ print("两房间 gridworld(确定性, 论文 Fig.1)")
487
+ print("=" * 70)
488
+ env = GridWorld(TWO_ROOM, gamma=0.99, stochastic=False)
489
+ print(" 状态数 %d, 动作数 %d" % (env.n_states, N_ACT))
490
+ ok, hall, info = check_two_room(env)
491
+
492
+ V, n = value_iteration(env, n_sweeps=200)
493
+ print("\n value iteration 200 遍 (primitive only): V(S) = %.4f" % V[env.start])
494
+ print(" 论文报告的最优值 v*(s0) = 0.99^17 = %.4f" % (0.99 ** 17))
495
+ print(" look-ahead 操作数 = %d" % n)
496
+
497
+ print("\n" + "=" * 70)
498
+ print("四房间 gridworld(随机, slip=2/3, 论文 Fig.6)")
499
+ print("=" * 70)
500
+ env4 = GridWorld(FOUR_ROOM, gamma=0.99, stochastic=True)
501
+ print(" 状态数 %d, 动作数 %d" % (env4.n_states, N_ACT))
502
+ ok4, halls4, info4 = check_four_room(env4)
503
+ # ★ 自检不过就让进程**非零退出** —— 否则坏几何又会被静默当成"实验做完了"
504
+ if not (ok and ok4):
505
+ print("\n✗ 布局自检未通过, 拒绝继续(退出码 1)")
506
+ raise SystemExit(1)
507
+ V4, n4 = value_iteration(env4, n_sweeps=200)
508
+ print("\n value iteration 200 遍 (primitive only): V(S) = %.4f" % V4[env4.start])
509
+ print(" look-ahead 操作数 = %d" % n4)
hibs_lnn/knowledge.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """knowledge.py — InternalKnowledge: Agent 的**内部知识层**。
2
+
3
+ ## 核心立场 (用户给的)
4
+
5
+ Knowledge ≠ Parameter
6
+
7
+ 参数只是**承载**知识的一种方式。OaK 式系统希望内部知识是**可组合的计算对象**,
8
+ 而不是 `y = f_θ(x)` 里一堆"你甚至不知道 θ 代表什么知识"的权重。
9
+
10
+ 所以这个类**不暴露 θ / W / α**,而是暴露**可计算的知识查询**:
11
+
12
+ "未来会发生什么?" -> predict()
13
+ "我做什么会导致什么?" -> dynamics()
14
+ "什么结果更好?" -> value()
15
+ "哪些过程是一个整体技能?" -> skills()
16
+ "我对 X 有多确定?" -> confidence()
17
+ "我有多少经验?" -> coverage_of()
18
+ "我该多快改变?" -> plasticity()
19
+
20
+ ## 四层知识 (用户给的编号)
21
+
22
+ Level 1 预测知识 K_P GVF / predictors "未来会发生什么"
23
+ Level 2 因果/转移 K_T T(s,a) "我做什么会导致什么"
24
+ Level 3 价值/策略 K_V V / Q / π "什么结果更好"
25
+ Level 4 抽象知识 K_A Options "哪些过程是一个整体技能"
26
+
27
+ 外加两类**元知识**:
28
+
29
+ uncertainty/ confidence "我对自己的预测有多确定"
30
+ coverage/ state_action 覆盖 "我对这个状态/动作到底有没有经验"
31
+ plasticity/ α / β "我应该多快改变这个知识" (接 IDBD)
32
+
33
+ ## 为什么 α 也算内部知识
34
+
35
+ w_i = knowledge "我知道什么"
36
+ α_i = knowledge about plasticity "我应该多快改变这个知识"
37
+ β_i = meta-knowledge "我的学习速度应该如何变化"
38
+
39
+ 这与 IDBD 主线直接接上。
40
+
41
+ ## 关键能力: 知识是**留下来的**
42
+
43
+ `to_dict()` / `load_dict()` 让 K 可以持久化比对 —— 这是"持续构建内部知识"的
44
+ 可判定形式: 不是"模型又训了一轮",而是**能指出知识具体变了什么**。
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import numpy as np
49
+
50
+ # ──────────────────────────────────────────────────────────────────────────
51
+ # Level 1: 预测知识 (GVF / Horde 式)
52
+ # ──────────────────────────────────────────────────────────────────────────
53
+
54
+
55
+ class GVF:
56
+ """General Value Function: 预测某个 cumulant 的折扣累积量。
57
+
58
+ V(s) = E[ Σ γ^k λ^k c_{t+k+1} | s_t = s ]
59
+
60
+ `c` = cumulant (要预测的信号), `γ` = 折扣, `λ` = 迹衰减。
61
+ Horde (Sutton 2011) 的做法: 一堆 GVF 并行学, 它们的预测**进入状态**,
62
+ 构成 agent 对世界的"理解"—— 这正是 OaK 说的 internal knowledge。
63
+ """
64
+
65
+ def __init__(self, name, dim, gamma=0.9, lam=0.8, lr=0.05, seed=0):
66
+ self.name = name
67
+ self.gamma = gamma
68
+ self.lam = lam
69
+ self.lr = lr
70
+ self.w = np.zeros(dim)
71
+ self.trace = np.zeros(dim)
72
+ self.rng = np.random.default_rng(seed)
73
+ self.steps = 0
74
+
75
+ def predict(self, phi):
76
+ return float(self.w @ phi)
77
+
78
+ def update(self, phi, cumulant, phi_next):
79
+ """TD(λ) 更新。"""
80
+ v, v_next = self.w @ phi, self.w @ phi_next
81
+ delta = cumulant + self.gamma * v_next - v
82
+ self.trace = self.gamma * self.lam * self.trace + phi
83
+ self.w += self.lr * delta * self.trace
84
+ self.steps += 1
85
+ return abs(delta)
86
+
87
+ def stats(self):
88
+ return {"name": self.name, "w_norm": float(np.linalg.norm(self.w)),
89
+ "steps": self.steps, "gamma": self.gamma, "lam": self.lam}
90
+
91
+
92
+ class GVFBank:
93
+ """一组 GVF。它们的预测拼起来就是 agent 的"世界理解"向量。"""
94
+
95
+ def __init__(self, dim, spec=None, seed=0):
96
+ self.spec = spec or [
97
+ ("next_acc", 0.95, 0.9), ("acc_trend", 0.9, 0.7),
98
+ ("forget_risk", 0.85, 0.8), ("regime_shift", 0.7, 0.5),
99
+ ]
100
+ self.gvfs = [GVF(n, dim, g, l, seed=seed + i)
101
+ for i, (n, g, l) in enumerate(self.spec)]
102
+
103
+ def outputs(self, phi):
104
+ """预测向量 —— 这就是"预测到的世界状态", 可作为下游特征。"""
105
+ return np.array([g.predict(phi) for g in self.gvfs])
106
+
107
+ def update(self, phi, cumulants, phi_next):
108
+ errs = []
109
+ for g, c in zip(self.gvfs, cumulants):
110
+ errs.append(g.update(phi, float(c), phi_next))
111
+ return float(np.mean(errs)) if errs else 0.0
112
+
113
+ def stats(self):
114
+ return {"n_gvf": len(self.gvfs),
115
+ "gvfs": [g.stats() for g in self.gvfs],
116
+ "out_norm": float(np.linalg.norm(self.outputs(np.ones_like(self.gvfs[0].w)))),
117
+ "mean_steps": float(np.mean([g.steps for g in self.gvfs])) if self.gvfs else 0.0}
118
+
119
+
120
+ # ────────���─────────────────────────────────────────────────────────────────
121
+ # 元知识: 覆盖度 / 可塑性
122
+ # ──────────────────────────────────────────────────────────────────────────
123
+
124
+
125
+ class Coverage:
126
+ """state-action 覆盖度 —— "我对这个 (s,a) 到底有没有经验"。"""
127
+
128
+ def __init__(self, n_dom):
129
+ self.n_dom = n_dom
130
+ self.n = np.zeros(n_dom) # 每动作被访问次数
131
+ self.n_total = 0
132
+
133
+ def observe(self, a):
134
+ if 0 <= a < self.n_dom:
135
+ self.n[a] += 1
136
+ self.n_total += 1
137
+
138
+ def of(self, a):
139
+ return float(self.n[a]) if 0 <= a < self.n_dom else 0.0
140
+
141
+ def gate(self, a):
142
+ """g = 1/√(1+n): 零覆盖 -> 1, 经验多 -> 0。"""
143
+ return float(1.0 / np.sqrt(1.0 + self.of(a)))
144
+
145
+ def stats(self):
146
+ nz = self.n[self.n > 0]
147
+ return {"total": int(self.n_total), "per_action": self.n.astype(int).tolist(),
148
+ "min": float(nz.min()) if len(nz) else 0.0,
149
+ "max": float(nz.max()) if len(nz) else 0.0,
150
+ "zero_actions": int((self.n == 0).sum())}
151
+
152
+
153
+ class Plasticity:
154
+ """元知识: α (该多快改变某个参数) 与 β (学习速度本身的变化)。
155
+
156
+ 这是 IDBD 主线在"内部知识"里的位置 —— 它**不是**知识本身,
157
+ 而是"关于知识该如何改变的"元知识。
158
+ """
159
+
160
+ def __init__(self, dim, alpha0=0.05, mu=0.01):
161
+ self.alpha0 = alpha0
162
+ self.mu = mu
163
+ self.beta = np.zeros(dim)
164
+ self.h = np.zeros(dim)
165
+ self.updates = 0
166
+
167
+ @property
168
+ def alpha(self):
169
+ return np.exp(np.clip(self.beta, -20, 20)) * self.alpha0
170
+
171
+ def update(self, phi, delta):
172
+ g = delta * phi * self.h
173
+ self.h = np.maximum(0.0, self.h * (1.0 - np.clip(self.alpha * phi ** 2, 0, 1)) + self.alpha * phi * delta)
174
+ self.beta = np.clip(self.beta + self.mu * g, -20, 20)
175
+ self.updates += 1
176
+
177
+ def stats(self):
178
+ a = self.alpha
179
+ return {"mean": float(a.mean()), "std": float(a.std()),
180
+ "min": float(a.min()), "max": float(a.max()),
181
+ "ratio_max_min": float(a.max() / max(a.min(), 1e-12)),
182
+ "updates": self.updates}
183
+
184
+
185
+ # ──────────────────────────────────────────────────────────────────────────
186
+ # InternalKnowledge: 统一的知识容器
187
+ # ──────────────────────────────────────────────────────────────────────────
188
+
189
+
190
+ class InternalKnowledge:
191
+ """K = {predictions, dynamics, value, options, uncertainty, coverage, plasticity}
192
+
193
+ ★ 不暴露参数, 只暴露**知识查询**。
194
+ """
195
+
196
+ def __init__(self, n_dom, dim_x, n_models=5, seed=0, alpha0=0.05, mu=0.01):
197
+ self.n_dom = int(n_dom)
198
+ self.dim_x = int(dim_x)
199
+ # Level 1 预测知识 K_P
200
+ self.predictions = GVFBank(dim_x, seed=seed)
201
+ # Level 2 因果/转移知识 K_T
202
+ from hibs_lnn.uncertainty_gate import TransitionEnsemble
203
+ self.dynamics_model = TransitionEnsemble(n_models=n_models, seed=seed)
204
+ self._dyn_fitted = False
205
+ # Level 3 价值/策略知识 K_V (由外部 proposer 提供, 这里只登记)
206
+ self.value_fn = None
207
+ self.policy = None
208
+ # Level 4 抽象知识 K_A
209
+ self.options = None
210
+ # 元知识
211
+ from hibs_lnn.uncertainty_gate import UncertaintyGate
212
+ self.uncertainty = UncertaintyGate(tau_C=1.0)
213
+ self.coverage = Coverage(self.n_dom)
214
+ self.plasticity = Plasticity(dim_x, alpha0=alpha0, mu=mu)
215
+ self.seed = seed
216
+
217
+ # ── 写入 ──────────────────────────────────────────────────────────
218
+ def fit_dynamics(self, X, Y):
219
+ self.dynamics_model.fit(X, Y, self.n_dom, None)
220
+ self._dyn_fitted = True
221
+ # 标定不确定性阈值
222
+ visits = self.coverage.n
223
+ n = len(X)
224
+ step = max(1, n // 100)
225
+ self.uncertainty.calibrate(
226
+ [self.dynamics_model.predict(X[i, :self.dim_x], int(X[i, -1]), visits)[1]
227
+ for i in range(0, n, step)])
228
+ return self
229
+
230
+ def register_value_fn(self, fn, policy=None):
231
+ """登记价值/策略知识 (K_V)。fn(s, a) -> V。"""
232
+ self.value_fn = fn
233
+ self.policy = policy
234
+ return self
235
+
236
+ def register_options(self, option_manager):
237
+ """登记抽象知识 (K_A)。"""
238
+ self.options = option_manager
239
+ return self
240
+
241
+ def observe_gvf(self, phi, cumulants, phi_next):
242
+ return self.predictions.update(phi, cumulants, phi_next)
243
+
244
+ def observe_action(self, a):
245
+ self.coverage.observe(a)
246
+
247
+ def observe_plasticity(self, phi, delta):
248
+ self.plasticity.update(phi, delta)
249
+
250
+ # ── 查询 (知识接口) ────────────────────────────────────────────────
251
+ def predict(self, s, a):
252
+ """未来会发生什么 (Level 1+2 合成)。"""
253
+ if not self._dyn_fitted:
254
+ return np.asarray(s, dtype=float).copy(), float("inf")
255
+ return self.dynamics_model.predict(s, a, self.coverage.n)
256
+
257
+ def value(self, s, a):
258
+ """什么结果更好 (Level 3)。
259
+
260
+ ★ **对未知输入必须返回 NaN, 而不是抛异常**。
261
+ 知识层被问到一个它没见过的 (s,a) 时, 正确反应是"我不知道", 不是崩。
262
+ (这条是实测抓到的: 测试里对从未出现过的动作 7 查询, value_fn 抛 KeyError
263
+ 一路炸穿 knowledge_of —— 而"承认不知道"恰恰是这一层的核心语义。)
264
+ """
265
+ if self.value_fn is None:
266
+ return float("nan")
267
+ try:
268
+ return float(self.value_fn(s, a))
269
+ except Exception:
270
+ return float("nan")
271
+
272
+ def skills(self):
273
+ """哪些过程是一个整体技能 (Level 4)。"""
274
+ if self.options is None:
275
+ return []
276
+ return [o.stats() for o in getattr(self.options, "options", [])]
277
+
278
+ def confidence(self, s, a):
279
+ """我对自己的预测有多确定 —— 返回 [0,1], 1 = 最确定。"""
280
+ _, unc = self.predict(s, a)
281
+ if not np.isfinite(unc):
282
+ return 0.0
283
+ tau = self.uncertainty.tau_U
284
+ if tau is None or not np.isfinite(tau) or tau <= 0:
285
+ return 0.5
286
+ return float(1.0 / (1.0 + unc / tau))
287
+
288
+ def coverage_of(self, a):
289
+ return self.coverage.of(a)
290
+
291
+ def knowledge_of(self, s, a):
292
+ """★★ 统一查询: 对任意 (s,a), agent 到底知道什么。
293
+
294
+ 这是 Knowledge≠Parameter 的直接体现 —— 问的是**内容**不是参数。
295
+ """
296
+ s_next, unc = self.predict(s, a)
297
+ val = self.value(s, a)
298
+ novel = bool(self.coverage_of(a) == 0)
299
+ known = bool(not novel and np.isfinite(unc))
300
+ return {
301
+ "state": np.round(np.asarray(s, dtype=float), 4).tolist(),
302
+ "action": int(a),
303
+ "predicted_next": np.round(np.asarray(s_next), 4).tolist(),
304
+ "uncertainty": float(unc) if np.isfinite(unc) else None,
305
+ "confidence": self.confidence(s, a),
306
+ "value": (val if np.isfinite(val) else None),
307
+ "coverage": self.coverage_of(a),
308
+ "coverage_gate": self.coverage.gate(a),
309
+ "trustworthy": bool(self.uncertainty.allow(s, a, self.coverage.n)[0]),
310
+ "novel": novel,
311
+ # ★ 这一项是整层的立命之处: agent 明确区分「我知道」与「我不知道」
312
+ "known": known,
313
+ "verdict": ("我不知道" if not known else
314
+ ("不太确定" if self.confidence(s, a) < 0.5 else "我知道")),
315
+ }
316
+
317
+ # ── 摘要 / 持久化 ─────────────────────────────────────────────────
318
+ def describe(self) -> dict:
319
+ """分层摘要: 这是我"知道了什么"的完整画像。"""
320
+ return {
321
+ "Level1_predictions": self.predictions.stats(),
322
+ "Level2_dynamics": {"fitted": self._dyn_fitted,
323
+ "n_models": len(getattr(self.dynamics_model, "Ws", []) or []),
324
+ "dim_x": self.dim_x, "n_dom": self.n_dom},
325
+ "Level3_value": {"registered": self.value_fn is not None,
326
+ "policy_registered": self.policy is not None},
327
+ "Level4_abstraction": {"n_options": len(self.skills()),
328
+ "options": self.skills()[:6]},
329
+ "meta_uncertainty": {"tau_U": self.uncertainty.tau_U,
330
+ "tau_C": self.uncertainty.tau_C},
331
+ "meta_coverage": self.coverage.stats(),
332
+ "meta_plasticity": self.plasticity.stats(),
333
+ }
334
+
335
+ def to_dict(self) -> dict:
336
+ """知识可以被**留下来** —— 持久化后能与旧版本比对。"""
337
+ return {
338
+ "coverage_n": self.coverage.n.tolist(),
339
+ "coverage_total": self.coverage.n_total,
340
+ "gvf_w": [g.w.tolist() for g in self.predictions.gvfs],
341
+ "gvf_steps": [g.steps for g in self.predictions.gvfs],
342
+ "dyn_W": [W.tolist() for W in (getattr(self.dynamics_model, "Ws", None) or [])],
343
+ "tau_U": self.uncertainty.tau_U,
344
+ "plasticity_beta": self.plasticity.beta.tolist(),
345
+ "plasticity_updates": self.plasticity.updates,
346
+ "n_options": len(self.skills()),
347
+ }
348
+
349
+ def growth(self, old_dict) -> dict:
350
+ """★ 与旧版本比对: 知识**具体**变了什么。
351
+
352
+ 这是"持续构建内部知识"的可判定形式 —— 不是"又训了一轮",
353
+ 而是能逐项指出知识的变化量。
354
+ """
355
+ if not old_dict:
356
+ return {"note": "no prior knowledge"}
357
+ out = {}
358
+ # 覆盖度变化
359
+ old_n = np.array(old_dict.get("coverage_n", [0] * self.n_dom), dtype=float)
360
+ out["coverage_delta"] = (self.coverage.n - old_n).astype(int).tolist()
361
+ out["coverage_total_delta"] = int(self.coverage.n_total - old_dict.get("coverage_total", 0))
362
+ # GVF 权重变化 (预测知识变了多少)
363
+ gw_old = old_dict.get("gvf_w")
364
+ if gw_old is not None and len(gw_old) == len(self.predictions.gvfs):
365
+ d = [float(np.linalg.norm(np.array(g.w) - np.array(o)))
366
+ for g, o in zip(self.predictions.gvfs, gw_old)]
367
+ out["gvf_weight_delta"] = np.round(d, 6).tolist()
368
+ out["gvf_weight_delta_mean"] = float(np.mean(d))
369
+ # 转移模型变化
370
+ dw_old = old_dict.get("dyn_W")
371
+ cur = getattr(self.dynamics_model, "Ws", None)
372
+ if dw_old and cur and len(dw_old) == len(cur):
373
+ d = [float(np.linalg.norm(np.array(W) - np.array(o))) for W, o in zip(cur, dw_old)]
374
+ out["dyn_weight_delta_mean"] = float(np.mean(d))
375
+ # 可塑性元知识变化
376
+ pb_old = old_dict.get("plasticity_beta")
377
+ if pb_old is not None and len(pb_old) == len(self.plasticity.beta):
378
+ out["plasticity_beta_delta"] = np.round(
379
+ self.plasticity.beta - np.array(pb_old), 6).tolist()
380
+ out["n_options_delta"] = len(self.skills()) - old_dict.get("n_options", 0)
381
+ return out
382
+
383
+ def stats(self):
384
+ return {"n_dom": self.n_dom, "dim_x": self.dim_x,
385
+ "dyn_fitted": self._dyn_fitted, "n_options": len(self.skills()),
386
+ "coverage_total": int(self.coverage.n_total)}
hibs_lnn/meta_rule_world.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ V31.0/V31.2: MetaRuleWorld — 元学习任务分布世界
3
+ ============================================
4
+ 元学习设定: 每个"任务" = 一个规则变体 (remap 参数化)。
5
+
6
+ 任务 T_i 的规则 = 一个寄存器重映射:
7
+ remap=None → 标准执行 (规则 A)
8
+ remap=(0,1) → R0↔R1 互换 (规则 B) [兼容旧接口: pair]
9
+ remap=(1,3,2,0) → 任意排列 perm[reg] [V31.2: 全 24 种排列]
10
+
11
+ V31.2 长流扩展 (S4 对称群, 24 条规则):
12
+ S4 = 1 identity + 6 对换 + 8 三轮换 + 6 四轮换 + 3 双对换
13
+ 训练: identity + 6 对换 + 5 三轮换 = 12 条
14
+ 未见: 3 三轮换 + 6 四轮换 + 3 双对换 = 12 条 (结构上不同 → "没学过的学习方法")
15
+
16
+ 目标 (Javed & White NeurIPS 2019 精神):
17
+ 不学"预测所有规则"的单一 head (V30.4 失败模式),
18
+ 而是学一个表示 φ, 使任意规则可以快速适应 (内循环 head 更新)。
19
+
20
+ 数据格式 (离线监督式):
21
+ context = 最近 window 个 READ 观察 (5 tokens each) → 预测下一个 READ (5 tokens)
22
+ 模型从观察历史推断当前规则 → 预测 EXEC 后的寄存器状态。
23
+ """
24
+
25
+ import torch
26
+ import random
27
+ from itertools import permutations
28
+ from .code_world import (
29
+ RegisterMachine, CodeSandboxWorld,
30
+ N_REGISTERS, INSTR_LEN, OPCODE_TOKEN_OFFSET,
31
+ OPCODES, OPCODES_INV,
32
+ )
33
+
34
+
35
+ def s4_groups():
36
+ """S4 按结构分组: (identity, swaps, 3cycles, 4cycles, double_swaps)。"""
37
+ allp = list(permutations(range(4)))
38
+ identity = [(0, 1, 2, 3)]
39
+ swaps, c3, c4, dbl = [], [], [], []
40
+ for p in allp:
41
+ if p == (0, 1, 2, 3):
42
+ continue
43
+ fixed = sum(1 for i in range(4) if p[i] == i)
44
+ if fixed == 2:
45
+ swaps.append(p) # 单对换
46
+ elif fixed == 1:
47
+ c3.append(p) # 三轮换
48
+ elif fixed == 0:
49
+ # 四轮换 (阶 4) vs 双对换 (阶 2)
50
+ if all(p[p[i]] == i for i in range(4)):
51
+ dbl.append(p) # 双对换 (Klein)
52
+ else:
53
+ c4.append(p) # 四轮换
54
+ return identity, swaps, c3, c4, dbl
55
+
56
+
57
+ # V31.2 规则划分: 训练 = identity + 6 对换 + 5 三轮换 (12)
58
+ # 未见 = 3 三轮换 + 6 四轮换 + 3 双对换 (12, 结构不同)
59
+ def build_rule_splits(seed=42):
60
+ identity, swaps, c3, c4, dbl = s4_groups()
61
+ rng = random.Random(seed)
62
+ rng.shuffle(c3); rng.shuffle(c4); rng.shuffle(dbl)
63
+ train = identity + swaps + c3[:5]
64
+ test = c3[5:] + c4 + dbl
65
+ return train, test
66
+
67
+
68
+ # 各 opcode 的寄存器操作数位置 (立即数位置不重映射):
69
+ # SET dst,val → a1 寄存器, a2 立即数 (0-99)
70
+ # MOV src,dst → a1,a2 寄存器
71
+ # ADD/SUB a,b,d → a1,a2,a3 寄存器
72
+ # INC/DEC reg → a1 寄存器
73
+ # SWP a,b → a1,a2 寄存器
74
+ _REG_OPPOS = {0: (), 1: (0,), 2: (0, 1), 3: (0, 1, 2), 4: (0, 1, 2),
75
+ 5: (0,), 6: (0,), 7: (0, 1)}
76
+
77
+
78
+ class MetaRuleMachine(RegisterMachine):
79
+ """RegisterMachine + 参数化寄存器重映射 (任意排列或 pair)。
80
+
81
+ V31.2 修复 (原 V31.0 bug): 只重映射**寄存器操作数**位置,
82
+ 立即数 (SET 的 a2, 0-99) 不受 remap 影响。
83
+ 原实现 `_remap` 作用于所有操作数 → 立即数被 mod 4 重映射,
84
+ "SET R0 42 → R1=42" 变成 "SET R1 2" — 规则语义被污染
85
+ (V31.0 世界自测的 remap 项其实一直失败, 计数器有 bug 显示 7/7)。
86
+ """
87
+
88
+ def __init__(self, V, remap_pair=None, remap=None):
89
+ super().__init__(V)
90
+ if remap is None:
91
+ if remap_pair is None:
92
+ remap = (0, 1, 2, 3)
93
+ else:
94
+ i, j = remap_pair
95
+ remap = tuple(j if k == i else (i if k == j else k)
96
+ for k in range(4))
97
+ self.remap_pair = remap_pair # 兼容旧接口 (V31.0)
98
+ self.remap = remap # V31.2: 完整排列 (identity = (0,1,2,3))
99
+
100
+ def _remap(self, reg: int) -> int:
101
+ return self.remap[reg % N_REGISTERS]
102
+
103
+ def step_execute(self) -> bool:
104
+ if self.halted or self.pc >= len(self.program):
105
+ self.halted = True
106
+ return False
107
+ if self.step_count >= self.max_steps:
108
+ self.halted = True
109
+ return False
110
+
111
+ opcode, a1, a2, a3 = self.program[self.pc]
112
+ name = OPCODES_INV[opcode]
113
+
114
+ if self.remap != (0, 1, 2, 3):
115
+ args = [a1, a2, a3]
116
+ for pos in _REG_OPPOS[opcode]:
117
+ args[pos] = self._remap(args[pos])
118
+ a1, a2, a3 = args
119
+
120
+ try:
121
+ if name == 'NOP':
122
+ pass
123
+ elif name == 'SET':
124
+ self.regs[a1 % N_REGISTERS] = a2 % self.V
125
+ elif name == 'MOV':
126
+ self.regs[a2 % N_REGISTERS] = self.regs[a1 % N_REGISTERS]
127
+ elif name == 'ADD':
128
+ self.regs[a3 % N_REGISTERS] = (
129
+ self.regs[a1 % N_REGISTERS] + self.regs[a2 % N_REGISTERS]
130
+ ) % self.V
131
+ elif name == 'SUB':
132
+ self.regs[a3 % N_REGISTERS] = max(
133
+ 0, self.regs[a1 % N_REGISTERS] - self.regs[a2 % N_REGISTERS]
134
+ )
135
+ elif name == 'INC':
136
+ self.regs[a1 % N_REGISTERS] = (
137
+ self.regs[a1 % N_REGISTERS] + 1
138
+ ) % self.V
139
+ elif name == 'DEC':
140
+ self.regs[a1 % N_REGISTERS] = max(
141
+ 0, self.regs[a1 % N_REGISTERS] - 1
142
+ )
143
+ elif name == 'SWP':
144
+ ri, rj = a1 % N_REGISTERS, a2 % N_REGISTERS
145
+ self.regs[ri], self.regs[rj] = self.regs[rj], self.regs[ri]
146
+ except Exception:
147
+ return False
148
+
149
+ self.pc += 1
150
+ self.step_count += 1
151
+ return True
152
+
153
+
154
+ class MetaRuleWorld(CodeSandboxWorld):
155
+ """代码执行世界, 规则由 remap_pair 参数化。观察格式与 CodeSandboxWorld 一致。"""
156
+
157
+ K = 5
158
+ READ_WINDOW = N_REGISTERS + 1 # 4 regs + 1 opcode
159
+ ACTIONS = ['READ', 'EXEC', 'RESET', 'NEWREG', 'TOME']
160
+
161
+ def __init__(self, V: int, remap_pair=None, remap=None,
162
+ max_program_len: int = 8):
163
+ self.V = V
164
+ self.TOME = V + 1
165
+ self.world_vocab_size = V + 2
166
+ self.max_program_len = max_program_len
167
+ self.remap_pair = remap_pair
168
+ self.remap = remap
169
+ self._new_episode()
170
+
171
+ def _new_episode(self):
172
+ self.vm = MetaRuleMachine(self.V, remap_pair=self.remap_pair,
173
+ remap=self.remap)
174
+ self.exec_count = 0
175
+ self.current_step = 0
176
+ self.n_erases = 0
177
+
178
+ prog_len = random.randint(2, self.max_program_len)
179
+ self.vm.generate_random_program(prog_len)
180
+
181
+ for i in range(N_REGISTERS):
182
+ self.vm.regs[i] = random.randint(0, min(self.V - 1, 50))
183
+
184
+ pre_steps = random.randint(0, min(2, len(self.vm.program) - 1))
185
+ for _ in range(pre_steps):
186
+ self.vm.step_execute()
187
+ self.exec_count += 1
188
+
189
+ def reset(self):
190
+ self._new_episode()
191
+
192
+
193
+ # ============================================================
194
+ # 任务数据生成 (离线监督式)
195
+ # ============================================================
196
+ # V31 观察格式 (8 tokens): [R0,R1,R2,R3, opcode+100, a1, a2, a3]
197
+ # → 完整指令可见, "预测 EXEC 后状态"是确定性问题 (信息完备)
198
+ # → 规则差异 (remap) 真正可学习: SET R0 42 → A: R0=42 / B: R1=42
199
+ # 注意: 这与 V30 系列不同 (V30 只有 opcode 无操作数 → 信息不足 → pred 2-8%)
200
+
201
+ def _instr_tokens(vm):
202
+ """当前 PC 指令的 4 个 token: [opcode+100, a1, a2, a3], 程序结束用 TOME。"""
203
+ if vm.halted or vm.pc >= len(vm.program):
204
+ return [vm.V + 1] * 4 # TOME
205
+ opcode, a1, a2, a3 = vm.program[vm.pc]
206
+ return [opcode + 100, min(a1, 99), min(a2, 99), min(a3, 99)]
207
+
208
+
209
+ def _world_obs(world):
210
+ """8-token 观察: [R0,R1,R2,R3, instr...]"""
211
+ vm = world.vm
212
+ state = [min(r, world.V - 1) for r in vm.regs]
213
+ return state + _instr_tokens(vm)
214
+
215
+
216
+ def gen_episode_pairs(world, V, max_len=8, window=3, stop_at_halt=False):
217
+ """
218
+ 从一个 episode 生成 (context, target) 监督对。
219
+
220
+ context = 最近 window 个观察拼接 (window*8 tokens)
221
+ target = EXEC 后的观察 (8 tokens)
222
+
223
+ 模型从观察历史推断当前规则 → 预测 EXEC 后的完整状态。
224
+ 由于观察含完整指令, 给定规则下 target 是确定性的。
225
+
226
+ stop_at_halt (V33 世界修复): 程序结束 (halted) 后立即停止,
227
+ 不生成含 TOME(201) 填充的 target → 每个 target 都有信息量
228
+ (旧世界 ~77.5% target 后 4 token 是 TOME, 虚高 full-8 acc)。
229
+ """
230
+ pairs = []
231
+ obs = torch.tensor(_world_obs(world), dtype=torch.long)
232
+ history = [obs]
233
+ for _ in range(max_len):
234
+ world.query(1) # EXEC
235
+ obs = torch.tensor(_world_obs(world), dtype=torch.long)
236
+ if stop_at_halt and world.vm.halted:
237
+ break # 程序结束: 不再追加 TOME 观察
238
+ history.append(obs)
239
+ for t in range(window, len(history)):
240
+ ctx = torch.cat(history[t - window:t]).long() # window*8
241
+ tgt = history[t].long() # 8
242
+ pairs.append((ctx, tgt))
243
+ return pairs
244
+
245
+
246
+ def gen_task_data(remap_pair, V, n_episodes=48, max_len=8, window=3,
247
+ seed=42, max_program_len=8, remap=None,
248
+ stop_at_halt=False):
249
+ """
250
+ 生成一个任务 (规则) 的数据集。
251
+ 返回: list of (context_ids, target_ids), 均 clamp 到 [0, V+1]。
252
+ """
253
+ rng_state = random.getstate()
254
+ random.seed(seed)
255
+ pairs = []
256
+ for _ in range(n_episodes):
257
+ w = MetaRuleWorld(V, remap_pair=remap_pair, remap=remap,
258
+ max_program_len=max_program_len)
259
+ pairs.extend(gen_episode_pairs(w, V, max_len=max_len, window=window,
260
+ stop_at_halt=stop_at_halt))
261
+ random.setstate(rng_state)
262
+ # clamp
263
+ pairs = [(c.clamp(0, V + 1), t.clamp(0, V + 1)) for c, t in pairs]
264
+ return pairs
265
+
266
+
267
+ def gen_task_data_split(remap_pair, V, n_support=48, n_query=24,
268
+ max_len=8, window=3, seed=42, max_program_len=8,
269
+ remap=None, stop_at_halt=False):
270
+ """生成任务的 support/query 划分 (不同 seed 保证不重叠)。"""
271
+ support = gen_task_data(remap_pair, V, n_episodes=n_support,
272
+ max_len=max_len, window=window,
273
+ seed=seed, max_program_len=max_program_len,
274
+ remap=remap, stop_at_halt=stop_at_halt)
275
+ query = gen_task_data(remap_pair, V, n_episodes=n_query,
276
+ max_len=max_len, window=window,
277
+ seed=seed + 1000, max_program_len=max_program_len,
278
+ remap=remap, stop_at_halt=stop_at_halt)
279
+ return support, query
280
+
281
+
282
+ def _self_test():
283
+ ok = 0
284
+ total = 0
285
+ def check(cond, msg):
286
+ nonlocal ok, total
287
+ total += 1
288
+ if cond:
289
+ ok += 1
290
+ print(f" OK {msg}")
291
+ else:
292
+ print(f" FAIL {msg}")
293
+
294
+ random.seed(42)
295
+ V = 100
296
+
297
+ # 1. 规则 A (无 remap): SET R0 42 → R0=42
298
+ vm_a = MetaRuleMachine(V=100, remap_pair=None)
299
+ vm_a.load_instruction([101, 0, 42, 0])
300
+ vm_a.step_execute()
301
+ check(vm_a.regs[0] == 42 and vm_a.regs[1] == 0,
302
+ f"A: SET R0 42 → R0={vm_a.regs[0]}, R1={vm_a.regs[1]}")
303
+
304
+ # 2. 规则 B (0,1): SET R0 42 → R1=42
305
+ vm_b = MetaRuleMachine(V=100, remap_pair=(0, 1))
306
+ vm_b.load_instruction([101, 0, 42, 0])
307
+ vm_b.step_execute()
308
+ check(vm_b.regs[1] == 42 and vm_b.regs[0] == 0,
309
+ f"B: SET R0 42 → R1={vm_b.regs[1]}, R0={vm_b.regs[0]}")
310
+
311
+ # 3. 规则 C (0,2): SET R0 42 → R2=42
312
+ vm_c = MetaRuleMachine(V=100, remap_pair=(0, 2))
313
+ vm_c.load_instruction([101, 0, 42, 0])
314
+ vm_c.step_execute()
315
+ check(vm_c.regs[2] == 42 and vm_c.regs[0] == 0,
316
+ f"C: SET R0 42 → R2={vm_c.regs[2]}, R0={vm_c.regs[0]}")
317
+
318
+ # 4. 规则 D (1,2): SET R1 42 → R2=42
319
+ vm_d = MetaRuleMachine(V=100, remap_pair=(1, 2))
320
+ vm_d.load_instruction([101, 1, 42, 0])
321
+ vm_d.step_execute()
322
+ check(vm_d.regs[2] == 42 and vm_d.regs[1] == 0,
323
+ f"D: SET R1 42 → R2={vm_d.regs[2]}, R1={vm_d.regs[1]}")
324
+
325
+ # 5. 数据生成: context/target shapes (8-token 观察)
326
+ w = MetaRuleWorld(V=100, remap_pair=(0, 1))
327
+ pairs = gen_episode_pairs(w, V, max_len=8, window=3)
328
+ check(len(pairs) > 0, f"episode pairs = {len(pairs)}")
329
+ ctx, tgt = pairs[0]
330
+ check(ctx.shape[0] == 24 and tgt.shape[0] == 8,
331
+ f"ctx={tuple(ctx.shape)} tgt={tuple(tgt.shape)} (24/8)")
332
+
333
+ # 5b. 信息完备性: 给定规则, target 确定 (SET R0 42 在规则 A/B 下不同)
334
+ random.seed(3)
335
+ wA = MetaRuleWorld(V=100, remap_pair=None)
336
+ wA.vm.program = [(1, 0, 42, 0)]; wA.vm.pc = 0; wA.vm.regs = [0, 0, 0, 0]
337
+ wA.vm.halted = False
338
+ obsA = _world_obs(wA)
339
+ wA.query(1) # EXEC
340
+ afterA = _world_obs(wA)
341
+ wB = MetaRuleWorld(V=100, remap_pair=(0, 1))
342
+ wB.vm.program = [(1, 0, 42, 0)]; wB.vm.pc = 0; wB.vm.regs = [0, 0, 0, 0]
343
+ wB.vm.halted = False
344
+ obsB = _world_obs(wB)
345
+ wB.query(1) # EXEC
346
+ afterB = _world_obs(wB)
347
+ check(obsA[:4] == obsB[:4], "same state before EXEC")
348
+ check(afterA[:4] == [42, 0, 0, 0], f"A after SET R0 42: {afterA[:4]}")
349
+ check(afterB[:4] == [0, 42, 0, 0], f"B after SET R0 42: {afterB[:4]}")
350
+ check(afterA[:4] != afterB[:4], "rule difference visible in targets")
351
+
352
+ # 6. support/query 划分
353
+ sup, qry = gen_task_data_split((0, 1), V, n_support=8, n_query=4, seed=42)
354
+ check(len(sup) > 0 and len(qry) > 0, f"support={len(sup)} query={len(qry)}")
355
+
356
+ # 7. 不同规则产生不同数据分布 (相同 seed, 相同程序 → 不同状态)
357
+ random.seed(7)
358
+ w1 = MetaRuleWorld(V=100, remap_pair=None)
359
+ w2 = MetaRuleWorld(V=100, remap_pair=(0, 1))
360
+ # 手动构造相同程序
361
+ prog = [(1, 0, 42, 0), (5, 0, 0, 0)] # SET R0 42; INC R0
362
+ w1.vm.program = prog[:]; w1.vm.pc = 0; w1.vm.regs = [0, 0, 0, 0]
363
+ w2.vm.program = prog[:]; w2.vm.pc = 0; w2.vm.regs = [0, 0, 0, 0]
364
+ w1.vm.step_execute(); w2.vm.step_execute()
365
+ s1 = w1.vm.regs[:]; s2 = w2.vm.regs[:]
366
+ check(s1[0] == 42 and s2[1] == 42,
367
+ f"same prog diverges: A={s1[:2]}, B={s2[:2]}")
368
+
369
+ # 8. V31.2 排列规则: 只重映射寄存器, 立即数不动
370
+ vm_p = MetaRuleMachine(V=100, remap=(1, 3, 2, 0))
371
+ vm_p.load_instruction([101, 0, 42, 0]) # SET R0 42
372
+ vm_p.step_execute()
373
+ check(vm_p.regs[1] == 42,
374
+ f"perm(1,3,2,0): SET R0 42 → R1={vm_p.regs[1]} (立即数 42 不受影响)")
375
+ vm_p2 = MetaRuleMachine(V=100, remap=(1, 3, 2, 0))
376
+ vm_p2.load_instruction([104, 0, 1, 2]) # SUB R0 R1 → R2
377
+ vm_p2.step_execute()
378
+ # SUB R0(→1) R1(→3) → R2(→2): regs[2] = regs[1] - regs[3] = 0
379
+ check(vm_p2.regs[2] == 0,
380
+ f"perm SUB: R0,R1→R2 remap 后 regs[2]=regs[1]-regs[3]")
381
+
382
+ print(f"\n {ok}/{total} passed")
383
+ return ok == total
384
+
385
+
386
+ if __name__ == "__main__":
387
+ _self_test()
hibs_lnn/oak_proposer.py ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """oak_proposer.py — OaK 提议器: 把 InternalKnowledge + Options 接进主回路。
2
+
3
+ ## 状态/动作的定义 (在这个问题里)
4
+
5
+ state = 各细区间的**能力画像** (维度 = n_fine) "我现在会什么"
6
+ action = 选哪个细区间训练 (0..n_fine-1)
7
+ next = 训练后的新能力画像
8
+ reward = Δ(any-time 准确率)
9
+
10
+ 这就是真实的持续学习闭环: **动作是"去练哪块", 环境是"练完能力怎么变"。**
11
+ `T(s,a) -> s'` 因此是一个**真正可学、数据里天然存在**的转移 —— 不是硬凑的。
12
+
13
+ ## 结构
14
+
15
+ OAKProposer
16
+ ├── base : RLProposer (Level 3 价值/策略知识, 复用已验证机制)
17
+ └── know : InternalKnowledge (Level 1/2/4 + 三类元知识)
18
+ ├── predictions GVFBank
19
+ ├── dynamics TransitionEnsemble
20
+ ├── options OptionManager
21
+ ├── uncertainty UncertaintyGate
22
+ ├── coverage Coverage
23
+ └── plasticity Plasticity
24
+
25
+ ## act() 的决策顺序
26
+
27
+ ① 有 option 在跑且未终止 -> 沿 option 的动作序列走
28
+ ② 否则取 base 的候选 -> 过**门控**; 被拒则退回已覆盖动作
29
+ ③ 记录 (state, action) -> 供 dynamics/option 发现用
30
+
31
+ ## E9 vs E10 就是一个 flag
32
+
33
+ use_options=False -> E9 (+coverage/uncertainty 门控)
34
+ use_options=True -> E10 (+Options)
35
+ 两者共用同一 base 与同一门控, 差异**只**在于是否使用时间抽象。
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import numpy as np
40
+
41
+ from hibs_lnn.knowledge import InternalKnowledge
42
+
43
+
44
+ class OAKProposer:
45
+ def __init__(self, fine_desc, n_fine, k=3, seed=0, tau=0.5,
46
+ mu=0.05, alpha0=0.2, explore_w=0.5, algo="autostep",
47
+ n_know=0, use_options=True, use_gate=True,
48
+ n_models=5, refresh_every=4, min_transitions=40,
49
+ n_regions=4, max_opt_len=3, opt_frac=1.0,
50
+ opt_mode="fixed", term_eps=0.05, stall_tol=0.0,
51
+ dyn_model="linear", n_regions_opt=16, use_subgoals=0):
52
+ from hibs_lnn.rl_proposer import RLProposer
53
+ self.n_fine = int(n_fine)
54
+ self.n = self.n_fine
55
+ self.k = int(k)
56
+ self.seed = seed
57
+ self.use_options = bool(use_options)
58
+ self.use_gate = bool(use_gate)
59
+ self.refresh_every = int(refresh_every)
60
+ self.min_transitions = int(min_transitions)
61
+ self.n_regions = int(n_regions)
62
+ self.max_opt_len = int(max_opt_len)
63
+ # ★ 诊断用: 只在一部分轮次允许启用 option。
64
+ # 若"Options 有害"确实来自 option 执行本身 (提交一段固定动作序列 ->
65
+ # 减少自适应性 / 降低探索多样性), 那么伤害应当**随 opt_frac 单调放大**。
66
+ # 若伤害与用量无关, 说明另有来源 (如 discovery 本身在扰动状态)。
67
+ self.opt_frac = float(opt_frac)
68
+ self._opt_round = 0
69
+ # ★ opt_mode: Option 的**执行方式** (用户 2026-09-14 指导的 K1 矩阵)
70
+ # "fixed" O1 固定动作序列 = open-loop (旧行为, D1 的病灶)
71
+ # "goal" O2 目标条件 + **每步重算动作** (闭环)
72
+ # "goal_term" O3 O2 + 自适应终止 β_o(s)
73
+ # "goal_term_override" O4 O3 + 不确定性抢占 (Option 从属于当前证据)
74
+ self.opt_mode = str(opt_mode)
75
+ self.term_eps = float(term_eps)
76
+ self.stall_tol = float(stall_tol)
77
+ self._opt_stall_d = None
78
+ self.override_count = 0
79
+ self.term_reasons = {}
80
+ self.replan_steps = 0
81
+ self.gate_blocked = 0 # _goal_action 因门控全拒返回 None
82
+ self.no_finite_unc = 0 # _goal_action 因无有限不确定性返回 None
83
+ self.goal_none = 0 # _goal_action 返回 None 总次数
84
+ self.start_blocked = 0 # 起 option 时算不出动作 (静默空转) 的次数
85
+ # ★ option 有效期。原实现一旦启动就**永不重选** (实测: 1 个 option
86
+ # 连跑 105 步 = 训练全程的 87%)。那不是 option, 是"锁死"。
87
+ # 正确语义: option 是**有名义长度的技能**, 跑完它的长度就结束,
88
+ # 由外层重新评估该用哪个技能 (或交回 base policy)。
89
+ self._opt_age = 0
90
+ self._opt_max_age = int(max_opt_len)
91
+ # ★ 世界模型 (实测: 线性 ridge 表达不了结构化动力学 —— 在 KeyDoor 这类
92
+ # 离散/条件性动力学上四个动作预测到**同一个**下一状态, 转移图退化成
93
+ # 几乎无边, option 发现为 0。计数式表格模型修掉了这个。)
94
+ self.dyn_model_kind = str(dyn_model)
95
+ self.n_regions_opt = int(n_regions_opt)
96
+ self.use_subgoals = int(use_subgoals)
97
+
98
+ # Level 3: 复用已验证的 RL 提议器
99
+ self.base = RLProposer(fine_desc, tau=tau, k=k, mu=mu, alpha0=alpha0,
100
+ explore_w=explore_w, algo=algo, n_know=n_know,
101
+ seed=seed)
102
+ # ★ 内部知识**惰性创建**: 状态维度在构造时未知。
103
+ # 主回路的状态是 `row` = 每**粗域**准确率 (长度 = --domains),
104
+ # 而动作数是细区间数 n_fine —— 两个维度**不同**。
105
+ # (实测: 按 n_fine 建 GVF 会在 observe 时撞
106
+ # `size 3 is different from 6`)。
107
+ self._know_cfg = dict(n_models=n_models, seed=seed, alpha0=alpha0, mu=mu)
108
+ self.know = None
109
+ self.dim_state = None
110
+ self._state_scale = 0.0
111
+
112
+ # 转移日志: (state_t, action, state_{t+1})
113
+ self.trans = []
114
+ self._prev_state = None
115
+ self._prev_pick = None
116
+ self._last_acc = {}
117
+ self._opt_queue = [] # 正在执行的 option 剩余动作
118
+ self._opt_active = None
119
+ self.refresh_count = 0
120
+ self.untrusted_picks = 0 # 提议落在不可信区的次数 (仅诊断)
121
+ self.fallback_count = 0 # 保留字段 (旧: 门控重定向次数, 已废弃)
122
+ self.option_steps = 0 # 由 option 决定的步数
123
+ self.option_starts = 0 # option 被启动的次数
124
+
125
+ # ── 惰性初始化 ──────────────────────────────────────────────────
126
+ def _ensure_know(self, dim_state):
127
+ if self.know is not None:
128
+ return self.know
129
+ self.dim_state = int(dim_state)
130
+ self.know = InternalKnowledge(self.n_fine, self.dim_state, **self._know_cfg)
131
+ if self.dyn_model_kind == "tabular":
132
+ from hibs_lnn.uncertainty_gate import TabularTransition
133
+ self.know.dynamics_model = TabularTransition()
134
+ self.know.register_value_fn(
135
+ lambda s, a: float(self._last_acc.get(int(a), np.nan)),
136
+ policy="rl-base")
137
+ return self.know
138
+
139
+ # ── 记录环境反馈 ────────────────────────────────────────────────
140
+ def set_state(self, state):
141
+ """每轮训练后由主回路喂入**新**能力画像。"""
142
+ st = np.asarray(state, dtype=float)
143
+ # 状态尺度 = 相邻两轮状态变化的典型幅度 (用于自适应阈值)
144
+ if getattr(self, "_prev_state", None) is not None:
145
+ _d = float(np.linalg.norm(st - np.asarray(self._prev_state, dtype=float)))
146
+ _a = getattr(self, "_state_scale", 0.0)
147
+ self._state_scale = _d if _a == 0.0 else 0.9 * _a + 0.1 * _d
148
+ self._ensure_know(st.shape[0])
149
+ if self._prev_state is not None and self._prev_pick is not None:
150
+ for a in (self._prev_pick if isinstance(self._prev_pick, (list, tuple))
151
+ else [self._prev_pick]):
152
+ self.trans.append((self._prev_state.copy(), int(a), st.copy()))
153
+ self.know.observe_action(int(a))
154
+ self._prev_state = st
155
+ self._last_acc = {i: float(v) for i, v in enumerate(st)
156
+ if np.isfinite(v)}
157
+
158
+ def set_pick(self, pick):
159
+ """记录本轮真正执行的动作。"""
160
+ self._prev_pick = list(pick) if isinstance(pick, (list, tuple)) else [pick]
161
+
162
+ # ── 动作选择 ───────────────────────────────────────────────────
163
+ def act(self):
164
+ # ① option 在执行中
165
+ if self.use_options and self._opt_active is not None and self.opt_mode != "fixed":
166
+ o = self._opt_active
167
+ s_now = self._prev_state
168
+ if s_now is None:
169
+ self._opt_active = None
170
+ else:
171
+ _, unc_a = self.know.predict(s_now, int(o.actions[0]))
172
+ tau = self.know.uncertainty.tau_U
173
+ if self.opt_mode == "goal_term_override":
174
+ if (not np.isfinite(unc_a)) or (tau and unc_a >= tau):
175
+ self.override_count += 1
176
+ self.term_reasons["override"] = self.term_reasons.get("override", 0) + 1
177
+ self._opt_active = None
178
+ return list(self.base.act())
179
+ # ★ 有效期检查 (对**所有**闭环模式生效)
180
+ self._opt_age += 1
181
+ if self._opt_age > self._opt_max_age:
182
+ self.term_reasons["expired"] = self.term_reasons.get("expired", 0) + 1
183
+ self._opt_active = None
184
+ self._opt_age = 0
185
+ return list(self.base.act())
186
+ if self.opt_mode in ("goal_term", "goal_term_override"):
187
+ done, why = o.terminated(
188
+ s_now, unc=unc_a, eps=self._eps_eff(),
189
+ tau_U=(tau if self.opt_mode == "goal_term_override" else None),
190
+ stall=self.stall_tol)
191
+ if done:
192
+ self.term_reasons[why] = self.term_reasons.get(why, 0) + 1
193
+ self._opt_active = None
194
+ return list(self.base.act())
195
+ a = self._goal_action(o)
196
+ if a is None:
197
+ self._opt_active = None
198
+ else:
199
+ self.replan_steps += 1
200
+ self.option_steps += 1
201
+ return [int(a)]
202
+ if self.use_options and self._opt_queue and self.opt_mode == "fixed":
203
+ a = int(self._opt_queue.pop(0))
204
+ self.option_steps += 1
205
+ if not self._opt_queue:
206
+ self._opt_active = None
207
+ return [a]
208
+
209
+ pick = list(self.base.act())
210
+
211
+ # ② ★ 门控**不用于 action selection**。
212
+ # 早先版本在这里把零覆盖动作重定向到 `argmin(visits)` 的已覆盖动作,
213
+ # 结果是灾难性的: 一旦某个动作被覆盖, 门控就把**所有**提议都指向它
214
+ # (实测 `per_action = [0,9,0,0,0,0]`, 9 轮只练了 1 个区间, 探索崩塌)。
215
+ #
216
+ # 语义纠正 (Q8 原文): "目标策略必须在现有经验中得到足够的信息支持" ——
217
+ # 这约束的是**模型驱动的规划决策** (凭预测去选), **不是**阻止 base
218
+ # policy 去探索。往零覆盖区探索恰恰是想要的: 覆盖度只能这样长起来。
219
+ # 所以门控只作用在**模型被咨询**的地方 (option 选择 / rollout),
220
+ # 那两处已由 OptionManager.select(exclude_unc=True) 与
221
+ # ens.rollout(gate=...) 各自保证。
222
+ if self.use_gate and self.know is not None:
223
+ # 只**记录**当前提议是否落在不可信区 (诊断用, 不做干预)
224
+ for a in pick:
225
+ if int(a) < self.n_fine:
226
+ _, unc = self.know.predict(self._prev_state
227
+ if self._prev_state is not None
228
+ else np.zeros(self.dim_state or 1), int(a))
229
+ tau = self.know.uncertainty.tau_U
230
+ if (not np.isfinite(unc)) or (tau and unc >= tau):
231
+ self.untrusted_picks += 1
232
+
233
+ # ③ 起一个新 option (受 opt_frac 限制)
234
+ self._opt_round += 1
235
+ _allow_opt = (self.opt_frac >= 1.0
236
+ or (self.opt_frac > 0 and
237
+ (self._opt_round % max(1, int(round(1.0 / self.opt_frac)))) == 0))
238
+ if (_allow_opt and self.use_options and self.know is not None
239
+ and self.know.options is not None and self._prev_state is not None):
240
+ o = self.know.options.select(self._prev_state, exclude_unc=True)
241
+ if o is not None and len(getattr(o, "actions", [])) > 1:
242
+ self._opt_active = o
243
+ # ★ 返回 option 的**第一个**动作。早先实现假设"起点的那个动作
244
+ # 已经执行过了", 于是只排队 actions[1:] —— 结果 option 的语义
245
+ # 只被用了一半, option_steps 实测只有 1~2 步。
246
+ self._opt_queue = [int(x) for x in o.actions[1:]]
247
+ if self.opt_mode == "fixed":
248
+ self._opt_queue = [int(x) for x in o.actions[1:]]
249
+ self.option_starts += 1
250
+ return [int(o.actions[0])]
251
+ a = self._goal_action(o) # 闭环: 立刻按当前状态算
252
+ if a is None:
253
+ # ★ 计数诚实性: 算不出动作的 option **不算启动**。
254
+ # 早先实现先 `option_starts += 1` 再判断, 于是"启动了 N 次但 0 步执行"
255
+ # 看起来像机制在跑, 实际是 inert -> 一个**静默的空转实验**。
256
+ self._opt_active = None
257
+ self.start_blocked += 1
258
+ return pick
259
+ self.option_starts += 1
260
+ self.replan_steps += 1
261
+ self.option_steps += 1
262
+ return [int(a)]
263
+ return pick
264
+
265
+ def _eps_eff(self):
266
+ """目标达成阈值 —— **必须与状态空间尺度匹配**。
267
+
268
+ 原实现固定 term_eps=0.05 (给"距离"用的量级), 但状态是**准确率向量**,
269
+ 单步变化就有 0.1~0.5 -> ‖s−g‖ < 0.05 几乎不可能 -> β_o 永不触发
270
+ -> `goal_term` 与 `goal` 给出**逐位相同**的结果 (实测: term={}).
271
+ """
272
+ sc = getattr(self, "_state_scale", 0.0)
273
+ return max(0.05, 0.25 * sc) if sc > 0 else 0.05
274
+
275
+ def _goal_action(self, opt):
276
+ """π_o(s_t) —— **按当前状态重算**动作, 而不是回放固定序列。
277
+
278
+ a = argmax_a [ ‖g−s‖ − ‖g−T(s,a)‖ ]
279
+ 即"哪一步动作让我朝子目标前进最多"。
280
+
281
+ ★ 用户指出的核心修正: **抽象的是目标, 不是动作**。
282
+ 固定序列执行 = "��发现了一段过去有效的脚本, 现在重放它",
283
+ 状态变化不再触发动作重算 -> 适应性下降 (D1 实测 r=-0.80)。
284
+ """
285
+ s_now = self._prev_state
286
+ g = np.asarray(opt.goal_center, dtype=float)
287
+ d_now = float(np.linalg.norm(np.asarray(s_now, dtype=float) - g))
288
+ best_a, best_gain = None, -np.inf
289
+ n_gate_rej = 0
290
+ n_unc_bad = 0
291
+ for a in range(self.n_fine):
292
+ if self.use_gate:
293
+ ok, _ = self.know.uncertainty.allow(s_now, a, self.know.coverage.n)
294
+ if not ok:
295
+ n_gate_rej += 1
296
+ continue
297
+ s_next, unc = self.know.predict(s_now, a)
298
+ if not np.isfinite(unc):
299
+ n_unc_bad += 1
300
+ continue
301
+ gain = d_now - float(np.linalg.norm(np.asarray(s_next, dtype=float) - g))
302
+ if gain > best_gain:
303
+ best_a, best_gain = a, gain
304
+ if best_a is None:
305
+ self.goal_none += 1
306
+ if n_gate_rej == self.n_fine:
307
+ self.gate_blocked += 1
308
+ elif n_unc_bad > 0:
309
+ self.no_finite_unc += 1
310
+ return best_a
311
+
312
+ # ── 奖励 → 参数更新 (+ 知识维护) ────────────────────────────────
313
+ def observe(self, pick, acc, any_time):
314
+ self.base.observe(pick, acc=acc, any_time=any_time)
315
+
316
+ def update(self, pick):
317
+ self.base.update(pick)
318
+ # ★★ 必须在这里把**实际执行的动作**记进 coverage。
319
+ # 实测抓到的 bug(第 4 次同型): `InternalKnowledge.observe_action()` 是
320
+ # coverage 的**唯一写入点**, 但主回路从来没调过它 -> `coverage.n` 永远全零
321
+ # -> `UncertaintyGate.allow()` 对**所有**动作返回 False
322
+ # (因为 `0 <= tau_C=1.0`) -> `_goal_action()` 的每一个候选都被 continue 掉
323
+ # -> `best_a = None` -> 回落到 base -> 轨迹与 E9 **逐位相同**
324
+ # (实测证据: goal/goal_term/override 三档的 alpha 与 e9 完全相同,
325
+ # `option_starts > 0` 但 `option_steps == 0`)。
326
+ if self.know is not None:
327
+ _pick = pick if isinstance(pick, (list, tuple)) else [pick]
328
+ for _a in _pick:
329
+ if int(_a) >= 0:
330
+ self.know.observe_action(int(_a))
331
+ # 预测知识: cumulant = 各区间能力本身 (GVF 学"能力会怎么走")
332
+ if self.know is not None and self._prev_state is not None:
333
+ phi = self._prev_state
334
+ cums = [float(np.mean(phi)),
335
+ float(np.max(phi) - np.min(phi)),
336
+ float(np.std(phi)),
337
+ float(np.mean(np.diff(np.sort(phi)[::-1][:3])) if self.n_fine >= 3 else 0.0)]
338
+ self.know.observe_gvf(phi, cums, phi)
339
+ # 周期性重建 world model + 重新发现 option
340
+ if (self.know is not None and len(self.trans) >= self.min_transitions
341
+ and self.refresh_count < len(self.trans) // max(1, self.refresh_every)):
342
+ self.refresh()
343
+ self.refresh_count += 1
344
+
345
+ def refresh(self):
346
+ """把转移日志灌进 dynamics ensemble -> 标定门控 -> 重新发现 options。"""
347
+ if self.know is None:
348
+ return
349
+ try:
350
+ X = np.array([np.append(s, a) for s, a, _ in self.trans])
351
+ Y = np.array([sp for _, _, sp in self.trans])
352
+ self.know.fit_dynamics(X, Y)
353
+ from hibs_lnn.option_manager import OptionManager
354
+ om = OptionManager(self.know.dynamics_model, self.know.uncertainty,
355
+ max_len=self.max_opt_len, seed=self.seed)
356
+ states = np.array([s for s, _, _ in self.trans])
357
+ acts = np.array([a for _, a, _ in self.trans])
358
+ # ★ 表格模型下按**真实状态**建图 (不是聚类中心)
359
+ n_uniq = len(np.unique(np.round(states, 6), axis=0))
360
+ nreg = (min(self.n_regions_opt, max(4, n_uniq))
361
+ if self.dyn_model_kind == "tabular"
362
+ else min(self.n_regions, max(2, len(states) // 10)))
363
+ if self.use_subgoals:
364
+ om.discover_subgoals(states, acts, n_regions=nreg)
365
+ else:
366
+ om.discover(states, acts, n_regions=nreg)
367
+ self.know.register_options(om)
368
+ except Exception as e: # 知识重建失败不该拖垮主实验
369
+ print("!! knowledge refresh 失败:", e, flush=True)
370
+
371
+ # ── 诊断 ───────────────────────────────────────────────────────
372
+ def stats(self):
373
+ s = self.base.proposer_stats() if hasattr(self.base, "proposer_stats") else {}
374
+ # ★ 真步长统计: 取自 base 的最近一条 trace (RLProposer 在那里记录 α)
375
+ _tr = getattr(self.base, "trace", None) or []
376
+ if _tr:
377
+ s["alpha_mean"] = _tr[-1].get("alpha_mean")
378
+ s["alpha_std"] = _tr[-1].get("alpha_std")
379
+ s["h_norm"] = _tr[-1].get("h_norm")
380
+ if self.know is None:
381
+ s.update({"n_trans": len(self.trans), "refreshes": 0, "n_options": 0,
382
+ "option_steps": self.option_steps,
383
+ "opt_frac": self.opt_frac,
384
+ "opt_mode": self.opt_mode,
385
+ "dyn_model": self.dyn_model_kind,
386
+ "use_subgoals": self.use_subgoals,
387
+ "replan_steps": self.replan_steps,
388
+ "state_scale": float(getattr(self, "_state_scale", 0.0)),
389
+ "eps_eff": float(self._eps_eff()),
390
+ "override_count": self.override_count,
391
+ "term_reasons": dict(self.term_reasons),
392
+ "option_starts": self.option_starts, "untrusted_picks": self.untrusted_picks,
393
+ "coverage_total": 0, "tau_U": None, "gvf_steps": [], "alpha_std": 0.0})
394
+ return s
395
+ s.update({
396
+ "n_trans": len(self.trans),
397
+ "refreshes": self.refresh_count,
398
+ "n_options": len(self.know.skills()),
399
+ "option_steps": self.option_steps,
400
+ "option_starts": self.option_starts,
401
+ "opt_frac": self.opt_frac,
402
+ "opt_mode": self.opt_mode,
403
+ "dyn_model": self.dyn_model_kind,
404
+ "use_subgoals": self.use_subgoals,
405
+ "replan_steps": self.replan_steps,
406
+ "override_count": self.override_count,
407
+ "gate_blocked": self.gate_blocked, "no_finite_unc": self.no_finite_unc,
408
+ "start_blocked": self.start_blocked,
409
+ "goal_none": self.goal_none,
410
+ "term_reasons": dict(self.term_reasons),
411
+ "untrusted_picks": self.untrusted_picks,
412
+ "coverage_total": int(self.know.coverage.n_total),
413
+ "tau_U": self.know.uncertainty.tau_U,
414
+ "gvf_steps": [g.steps for g in self.know.predictions.gvfs],
415
+ # ★ 不要放在 "alpha_std" 这个键上 —— 它会**覆盖** base.proposer_stats()
416
+ # 里真正的步长统计。早先版本就是这么写的, 结果 JSON 里几个不同算法
417
+ # (idbd / idbd-raw / cidbd) 的 alpha_std 全是同一个**未被更新**的死对象
418
+ # 的值 (2.78e-17), 看起来像"算法不生效"。真正在用的 α 在 base.trace。
419
+ "know_alpha_std": float(self.know.plasticity.alpha.std()),
420
+ })
421
+ return s
422
+
423
+ def stats_extra(self):
424
+ """主实验落盘用。"""
425
+ if self.know is None:
426
+ return {"knowledge": {"n_options": 0, "coverage": {}, "plasticity": {},
427
+ "tau_U": None, "options": []},
428
+ "oak": {"use_options": self.use_options, "use_gate": self.use_gate,
429
+ "n_trans": len(self.trans), "refreshes": 0,
430
+ "option_steps": self.option_steps,
431
+ "option_starts": self.option_starts,
432
+ "untrusted_picks": self.untrusted_picks}}
433
+ d = self.know.describe()
434
+ return {"knowledge": {
435
+ "n_options": d["Level4_abstraction"]["n_options"],
436
+ "coverage": d["meta_coverage"],
437
+ "plasticity": d["meta_plasticity"],
438
+ "tau_U": d["meta_uncertainty"]["tau_U"],
439
+ "options": d["Level4_abstraction"]["options"],
440
+ }, "oak": {"use_options": self.use_options, "use_gate": self.use_gate,
441
+ "n_trans": len(self.trans), "refreshes": self.refresh_count,
442
+ "option_steps": self.option_steps,
443
+ "option_starts": self.option_starts,
444
+ "untrusted_picks": self.untrusted_picks}}
hibs_lnn/option_manager.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """option_manager.py — Options / 时间抽象 (OaK 的 O)。
2
+
3
+ ## Option 的三要素 (用户定义)
4
+
5
+ o = (I_o, π_o, β_o)
6
+
7
+ I_o initiation set 什么时候可以启动这个 option
8
+ π_o option policy 启动后怎么行动 (这里是**一串子动作**, 不是单个动作)
9
+ β_o termination 什么时候结束 —— **学习式** P(terminate | s, o)
10
+
11
+ ## ★ 不从 reward 定义 option, 而从「可预测的状态转移结构」发现
12
+
13
+ 用户: *"Option 不一定首先由 reward 定义, 而可以由可预测的状态转移结构产生。"*
14
+
15
+ 我们已有 `TransitionEnsemble` (带分歧 U_T)。发现流程:
16
+
17
+ 1. 用过渡模型把能力状态 s 映射到预测状态 ŝ, 聚类成 region
18
+ 2. 建 region 转移图: 边 (A --u--> B) 的**可预测性** = 1/(1+U_T)
19
+ 3. 在图上找多步可靠路径 A → C (要求每一步 U_T < τ_U)
20
+ 4. 每条这样的路径 = 一个 option: π_o = 该动作序列, I_o = A 区域
21
+ 5. β_o 用真实观测学: P(到达目标区域 | s, o) —— 学习式终止, 不是固定阈值
22
+
23
+ **为什么这比手工写 "Option1=加磁通 / Option2=稳定" 强**: 手工定义只编码了人类先验;
24
+ 从转移结构发现是在编码 **"哪些行为的后果我可以可靠预测"** —— 那才是可利用的时间抽象。
25
+
26
+ ## 与 planning 的衔接
27
+
28
+ 高层: s_t → 选 option → **rollout transition model** → 选动作序列
29
+ 这正是 OaK 的 abstraction → transition model → planning 路线。
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import numpy as np
34
+
35
+
36
+ class Option:
37
+ """o = (I_o, π_o, β_o)。"""
38
+
39
+ def __init__(self, oid, actions, start_center, goal_center, uncs=None,
40
+ regions=None, start_region=None, goal_region=None):
41
+ self.oid = int(oid)
42
+ self.actions = list(actions) # π_o: 动作序列 (时间抽象的核心)
43
+ self.start_center = np.asarray(start_center, dtype=float) # I_o 的中心
44
+ self.goal_center = np.asarray(goal_center, dtype=float) # 目标区域
45
+ self.uncs = list(uncs or []) # 逐步不确定性 (用于可信度)
46
+ self.visits = 0
47
+ self.success = 0
48
+ # β_o 的线性参数: P(terminate | s, o) = sigmoid(w·[s, goal, unc])
49
+ self.w_beta = np.zeros(3 * len(self.goal_center) + 1)
50
+ # ── ★ 区域归属 (无阈值的终止判据) ────────────────────────────
51
+ # 旧判据 `‖s − goal_center‖ < eps` 在**离散状态空间**里会退化:
52
+ # KeyDoor 的 vec() 是 one-hot(pos) ⊕ has_key ⊕ door_open, 两个不同
53
+ # 位置的距离是 √2≈1.414、仅一个 bit 不同的是 1.0, 而
54
+ # `init_radius = max(0.5, median(NN)·0.75) = 0.754` **只接受精确到达**
55
+ # → 实测 β_o 因达成而触发的比例 **0/2737**, 全部靠预算耗尽退出。
56
+ # 正确判据: "s 是否落在该 option 的目标**区域**里" —— 用 k-means 的
57
+ # Voronoi 归属 (argmin ‖s − centers‖) 判定, 无阈值、覆盖全空间。
58
+ self.regions = None if regions is None else np.asarray(regions, dtype=float)
59
+ self.start_region = start_region
60
+ self.goal_region = goal_region
61
+
62
+ # ── ★ 区域归属 ──
63
+ def region_of(self, s):
64
+ """s 落在哪个区域 (k-means Voronoi 归属)。regions 未设时返回 None。"""
65
+ if self.regions is None or len(self.regions) == 0:
66
+ return None
67
+ d = np.linalg.norm(self.regions - np.asarray(s, dtype=float)[None, :], axis=1)
68
+ return int(np.argmin(d))
69
+
70
+ def in_goal_region(self, s):
71
+ """β_o 的**主判据**: s 是否已进入目标区域。"""
72
+ if self.goal_region is None:
73
+ return False
74
+ return self.region_of(s) == self.goal_region
75
+
76
+ # ── I_o: 启动条件 (到起点的距离在半径内) ──
77
+ def initiable(self, s, radius=0.5):
78
+ # ★ 优先用区域归属 (与 in_goal_region 对称, 无阈值退化问题);
79
+ # 未设区域时退回距离判据 (保留旧行为)。
80
+ if self.start_region is not None:
81
+ r = self.region_of(s)
82
+ if r is not None and r == self.start_region:
83
+ return True
84
+ return float(np.linalg.norm(np.asarray(s) - self.start_center)) <= radius
85
+
86
+ # ── β_o: **学习式终止概率** ──
87
+ def _beta_feat(self, s, unc=0.0):
88
+ s = np.asarray(s, dtype=float)
89
+ g = self.goal_center
90
+ return np.concatenate([s, g - s, [unc, 1.0]])
91
+
92
+ def beta_prob(self, s, unc=0.0):
93
+ z = float(self.w_beta @ self._beta_feat(s, unc))
94
+ return 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30)))
95
+
96
+ def learn_termination(self, samples, lr=0.05, epochs=200):
97
+ """从真实观测学 β_o。samples: [(s, unc, terminated_bool), ...]
98
+
99
+ 这才是「学习式 termination」—— 而不是 `if error < threshold: stop()`。
100
+ """
101
+ if not samples:
102
+ return
103
+ F = np.array([self._beta_feat(s, u) for s, u, _ in samples])
104
+ y = np.array([1.0 if t else 0.0 for _, _, t in samples])
105
+ for _ in range(epochs):
106
+ p = 1.0 / (1.0 + np.exp(-np.clip(F @ self.w_beta, -30, 30)))
107
+ grad = F.T @ (p - y) / len(y)
108
+ self.w_beta -= lr * grad
109
+ return self
110
+
111
+ def should_terminate(self, s, unc=0.0, thresh=0.5):
112
+ return self.beta_prob(s, unc) >= thresh
113
+
114
+ # ── ★ 闭环保守派接口 (用户 2026-09-14 指导) ──────────────────────
115
+ def goal_distance(self, s):
116
+ """到子目标的距离 —— 这是 β_o 与 π_o 共用的量。"""
117
+ return float(np.linalg.norm(np.asarray(s, dtype=float) - self.goal_center))
118
+
119
+ def terminated(self, s, unc=0.0, eps=0.05, tau_U=None, stall=0.0):
120
+ """自适应终止 β_o(s): 任一条件命中即退出。
121
+
122
+ β_o(s) = 1 若 ⓪ 进入**目标区域** in_goal_region(s) ← 主判据
123
+ ① 目标达成 ||s - g|| < eps
124
+ ② 不确定性过高 U_T > tau_U (风险升高)
125
+ ③ 停滞 本轮进展 < stall
126
+ β_o(s) = 0 否则
127
+
128
+ ★ ⓪ 是本轮新增的主判据。旧判据仅 ①(距离 < eps), 在**离散或高维**
129
+ 状态空间里会退化 —— 实测 (KeyDoor, `vec()` 为 one-hot ⊕ 2 bit):
130
+ 距离离散化为 {0, 1, 1.732, 2}, 而 eps=0.754 只接受精确到达
131
+ → 2737 次执行中因「达成」触发 **0 次**, 全部靠预算耗尽退出。
132
+ 区域归属 (k-means Voronoi) 无阈值、覆盖全空间, 故设为主判据;
133
+ ① 保留为兜底 (regions 未设时行为不变)。
134
+
135
+ ★ 固定长度执行是 open-loop 的病根: "即使当前情况已经不适合这个 option,
136
+ 它可能还在继续执行"。这里让 option **每步都可以退出**。
137
+ """
138
+ if self.in_goal_region(s):
139
+ return True, "goal_reached"
140
+ if self.goal_distance(s) < eps:
141
+ return True, "goal_reached"
142
+ if tau_U is not None and np.isfinite(unc) and unc > tau_U:
143
+ return True, "high_uncertainty"
144
+ if stall > 0 and getattr(self, "_last_d", None) is not None:
145
+ if self._last_d - self.goal_distance(s) < stall:
146
+ return True, "stalled"
147
+ return False, ""
148
+
149
+ def progress(self, s_from, s_to):
150
+ """朝目标前进多少 (正 = 更近)。"""
151
+ return self.goal_distance(s_from) - self.goal_distance(s_to)
152
+
153
+ def stats(self):
154
+ rate = (self.success / self.visits) if self.visits else 0.0
155
+ # ★ 必须暴露 actions: 多步**动作序列**正是 option 的全部意义
156
+ # (时间抽象 = s -> a1..ak)。早先只返回 `len`, 日志里全是 actions=None,
157
+ # 等于把 option 最有信息量的部分丢了。
158
+ return {"oid": self.oid, "len": len(self.actions),
159
+ "actions": [int(x) for x in self.actions],
160
+ "start": np.round(self.start_center, 3).tolist(),
161
+ "goal": np.round(self.goal_center, 3).tolist(),
162
+ "visits": self.visits, "success_rate": round(rate, 3),
163
+ "mean_unc": round(float(np.mean(self.uncs)) if self.uncs else 0.0, 5)}
164
+
165
+
166
+ class OptionManager:
167
+ """从可预测的状态转移结构中**发现** options。"""
168
+
169
+ def __init__(self, ensemble, gate, max_len=4, min_edges=3, seed=0):
170
+ self.T = ensemble
171
+ self.gate = gate
172
+ self.max_len = int(max_len)
173
+ # 启动集半径: 由 discover 按区域尺度自适应推断
174
+ self.init_radius = 0.75
175
+ self.min_edges = int(min_edges)
176
+ self.options = []
177
+ self.regions = None # region 中心
178
+ self.graph = {} # (region, action) -> (next_region, unc)
179
+ self.rng = np.random.default_rng(seed)
180
+
181
+ # ───────── 发现 ─────────
182
+ def discover(self, states, actions, n_regions=4, tau_U=None):
183
+ """输入: 观测到的 (状态, 动作) 对。输出: options 列表。
184
+
185
+ states : (N, dim_x) 能力状态
186
+ actions: (N,) 当时训练了哪个域
187
+ """
188
+ S = np.asarray(states, dtype=float)
189
+ A = np.asarray(actions, dtype=int)
190
+ n_dom = int(A.max()) + 1 if len(A) else 1
191
+ visits = np.zeros(n_dom)
192
+ for a in A:
193
+ visits[a] += 1
194
+
195
+ # 1) 聚类成 region (用最简单可复现的 k-means, 避免引入依赖)
196
+ self.regions, assign = self._kmeans(S, n_regions)
197
+
198
+ # 2) 建转移图: 对每个 (region, action) 预测落点与不确定性
199
+ self.graph = {}
200
+ for r in range(n_regions):
201
+ for u in range(n_dom):
202
+ c = self.regions[r]
203
+ s_next, unc = self.T.predict(c, u, visits) # 已是绝对状态
204
+ nxt = self._nearest_region(s_next)
205
+ self.graph[(r, u)] = (nxt, unc)
206
+
207
+ # 3) 定 τ_U (若未给) —— 用图上所有边的分歧分位
208
+ uncs = [self.graph[k][1] for k in self.graph]
209
+ uncs = [u for u in uncs if np.isfinite(u)]
210
+ if tau_U is None:
211
+ # 用**中位数**而非高分位: 我们要的是「一半的可预测边」, 不是几乎全部。
212
+ tau_U = float(np.median(uncs)) if uncs else float("inf")
213
+ self.tau_U = tau_U
214
+
215
+ # 4) 找可靠路径 (每步 unc < tau_U 且确实前进了) = options
216
+ S = np.asarray(states, dtype=float)
217
+ self.options = []
218
+ oid = 0
219
+ for r0 in range(n_regions):
220
+ for L in range(2, self.max_len + 1):
221
+ for path in self._reliable_paths(r0, L, tau_U):
222
+ acts = path
223
+ goal_r = self._walk(r0, acts)
224
+ if goal_r == r0:
225
+ continue # 没前进, 不是 option
226
+ uncs_p = [self.graph[(self._walk(r0, acts[:i]), acts[i])][1]
227
+ for i in range(len(acts))]
228
+ # ★ 起点/目标吸附到**真实观测状态** (质心可能够不着)
229
+ # + 带上区域归属 (无阈值的 β_o 判据)
230
+ self.options.append(Option(
231
+ oid, acts, self._snap(S, self.regions[r0]),
232
+ self._snap(S, self.regions[goal_r]), uncs_p,
233
+ regions=self.regions, start_region=r0, goal_region=goal_r))
234
+ oid += 1
235
+ # 去重 (同起点同目标保留最短的)
236
+ best = {}
237
+ for o in self.options:
238
+ k = (tuple(np.round(o.start_center, 3)), tuple(np.round(o.goal_center, 3)))
239
+ if k not in best or len(o.actions) < len(best[k].actions):
240
+ best[k] = o
241
+ self.options = list(best.values())
242
+ for i, o in enumerate(self.options):
243
+ o.oid = i
244
+ # 自适应启动集半径: 最近邻区域距离中位数的 0.75 倍 (与状态空间尺度匹配)
245
+ if len(self.regions) > 1:
246
+ _D = np.linalg.norm(self.regions[:, None, :] - self.regions[None, :, :], axis=-1)
247
+ np.fill_diagonal(_D, np.inf)
248
+ self.init_radius = float(max(0.5, np.median(_D.min(axis=1)) * 0.75))
249
+ return self.options
250
+
251
+ def _reliable_paths(self, r0, L, tau_U):
252
+ """长度 L 的路径, 每步 unc < tau_U。用 BFS 枚举 (空间很小)。"""
253
+ out = []
254
+ n_dom = max(u for _, u in self.graph) + 1 if self.graph else 1
255
+ frontier = [(r0, [])]
256
+ for depth in range(L):
257
+ nxt = []
258
+ for r, seq in frontier:
259
+ for u in range(n_dom):
260
+ e = self.graph.get((r, u))
261
+ if e is None or not np.isfinite(e[1]) or e[1] > tau_U + 1e-12:
262
+ # ★ 用 `>` 而不是 `>=`: 确定性转移 (unc=0) 是**最可靠**的,
263
+ # 在 tau_U 也等于 0 时绝不能被筛掉 —— 否则"完全可预测的边"
264
+ # 全军覆没。实测: 计数式表格模型给出 tau_U=0.0, 旧的 `>=`
265
+ # 使 OptionManager 在结构清晰的 MDP 上发现 0 个 option。
266
+ continue
267
+ if e[0] == r:
268
+ continue # 不前进的边不走
269
+ nxt.append((e[0], seq + [u]))
270
+ if depth == L - 1:
271
+ out.extend(seq for _, seq in nxt)
272
+ frontier = nxt
273
+ if not frontier:
274
+ break
275
+ return out
276
+
277
+ def _walk(self, r0, acts):
278
+ r = r0
279
+ for u in acts:
280
+ e = self.graph.get((r, u))
281
+ if e is None:
282
+ return r
283
+ r = e[0]
284
+ return r
285
+
286
+ def _nearest_region(self, s):
287
+ d = np.linalg.norm(self.regions - np.asarray(s), axis=1)
288
+ return int(np.argmin(d))
289
+
290
+ @staticmethod
291
+ def _kmeans(X, k, iters=50, seed=0):
292
+ rng = np.random.default_rng(seed)
293
+ C = X[rng.choice(len(X), size=min(k, len(X)), replace=False)].copy()
294
+ a = np.zeros(len(X), dtype=int) # 防 iters=0 时未绑定
295
+ for _ in range(iters):
296
+ d = np.linalg.norm(X[:, None, :] - C[None, :, :], axis=2)
297
+ a = np.argmin(d, axis=1)
298
+ for j in range(len(C)):
299
+ if (a == j).any():
300
+ C[j] = X[a == j].mean(0)
301
+ return C, a
302
+
303
+ # ───────── 高层选择 (planning 入口) ─────────
304
+ # ── ★ 子目标驱动的发现 (用户 2026-09-14 指导的 Option 形式) ──────
305
+ def discover_subgoals(self, states, actions, n_regions=8, tgt=0.0,
306
+ min_path=2, max_path=5):
307
+ """按 `Option=(I_o, g_o, π_o, β_o)` **目标导向地**发现 option。
308
+
309
+ ## 为什么需要它 (实测动机)
310
+
311
+ 原来的 `discover()` 找的是「**任意**可靠的多步路径」:
312
+ ① 没有目标导向 —— 终点是不是"有意义的地方"不管
313
+ ② 路径上**每条**边都要 unc < tau_U(中位数) -> 长度 4 的存活率 ~0.5^4=6%
314
+ ③ 要求长度**恰好** = L
315
+ 结果: 在一个**有明显的"拿钥匙→开门"技能链**的 MDP 上, 即使图里有
316
+ 50 条前进边, 也只发现 0~1 个 option。**这是实现的问题, 不是环境的。**
317
+
318
+ ## 正确做法: 先找**子目标**, 再找通往它的路
319
+
320
+ 1. 建区域转移图 (同 discover)
321
+ 2. 算每个区域在图上的**介数中心性** —— 高介数 = 通往别处必须经过的
322
+ 咽喉 (bottleneck)。这正是"拿钥匙""开门"这类子目标的特征。
323
+ 3. 每个 bottleneck 区域 b 成为一个子目标:
324
+ g_o = centers[b]
325
+ π_o = 从各起点区域到 b 的**最短动作序列** (在图上 BFS)
326
+ I_o = 那些起点区域
327
+ 4. 允许多条不同长度的路径 (min_path..max_path), 不再要求恰好 = L
328
+ """
329
+ self.regions, _ = self._kmeans(np.asarray(states, dtype=float), n_regions)
330
+ n_dom = self.T.n_dom
331
+ visits = np.bincount(np.asarray(actions, dtype=int), minlength=n_dom).astype(float)
332
+
333
+ # 1. 区域转移图
334
+ self.graph = {}
335
+ for r in range(n_regions):
336
+ for u in range(n_dom):
337
+ s_next, unc = self.T.predict(self.regions[r], u, visits)
338
+ self.graph[(r, u)] = (self._nearest_region(s_next), unc)
339
+
340
+ uncs = np.array([v[1] for v in self.graph.values() if np.isfinite(v[1])])
341
+ self.tau_U = float(np.median(uncs)) if len(uncs) else float("inf")
342
+
343
+ # 2. 邻接 (每个区域的出边, 按 unc 从小到大)
344
+ adj = {r: [] for r in range(n_regions)}
345
+ for (r, u), (nxt, unc) in self.graph.items():
346
+ if nxt != r and np.isfinite(unc):
347
+ adj[r].append((nxt, u, unc))
348
+ for r in adj:
349
+ adj[r].sort(key=lambda t: t[2])
350
+
351
+ # 3. ★ 割点/桥找咽喉 —— **不用介数中心性**
352
+ # 实测证据 (选项验证技能 FM-17, 四房间 gridworld): 介数最高的格
353
+ # **不是**走廊格。介数衡量的是"最短路径经过次数", 而走廊的语义是
354
+ # "去掉它图就断开" —— 那是**割点(articulation point)**。
355
+ # 用错了统计量 -> 子目标选在无关处 -> π_o 朝一个无意义的地点走。
356
+ aps = self._articulation_points(adj, n_regions)
357
+ if not aps:
358
+ # 图无割点 (如完全连通): 退回到"通往最多区域"的节点,
359
+ # 这比介数中心性更贴近"咽喉"的意图, 且不退化成"选不到子目标"
360
+ aps = set(np.argsort(-np.array([len(adj[r]) for r in range(n_regions)]))[:2].tolist())
361
+
362
+ # 4. 每个割点区域 = 一个子目标; 用 BFS 找通往它的动作序列
363
+ S = np.asarray(states, dtype=float)
364
+ order_ap = sorted(aps)
365
+ self.options = []
366
+ oid = 0
367
+ for b in order_ap:
368
+ for src in range(n_regions):
369
+ if src == b:
370
+ continue
371
+ seq = self._bfs_action_path(adj, src, b, max_path)
372
+ if seq is None or len(seq) < min_path:
373
+ continue
374
+ uncs_seq = [self.graph[(src, seq[0])][1]]
375
+ # ★ 目标/起点必须是**真实观测到的状态**, 不能是 k-means 质心。
376
+ # 质心是几个状态的软平均, 可能不对应任何真实状态 ->
377
+ # 以它为目标的 π_o 会朝一个"够不着"的点走。
378
+ # 实测: macro 宏执行的成功率只有 14.4% (质心目标下),
379
+ # 即 85.6% 的 option 执行**到不了自己的目标**。
380
+ self.options.append(Option(oid, seq, self._snap(S, self.regions[src]),
381
+ self._snap(S, self.regions[b]), uncs_seq,
382
+ regions=self.regions,
383
+ start_region=src, goal_region=b))
384
+ oid += 1
385
+ if oid >= 40:
386
+ break
387
+ if oid >= 40:
388
+ break
389
+ # 自适应启动集半径: 最近邻区域距离中位数的 0.75 倍 (与状态空间尺度匹配)
390
+ if len(self.regions) > 1:
391
+ _D = np.linalg.norm(self.regions[:, None, :] - self.regions[None, :, :], axis=-1)
392
+ np.fill_diagonal(_D, np.inf)
393
+ self.init_radius = float(max(0.5, np.median(_D.min(axis=1)) * 0.75))
394
+ return self.options
395
+
396
+ @staticmethod
397
+ def _snap(S, center):
398
+ """把 k-means 质心**吸附到最近的真实观测状态**。
399
+
400
+ ★ 为什么必须做: 质心是若干状态的软平均, 可能不对应任何真实状态。
401
+ 以质心为目标 -> π_o 朝一个"够不着"的点走 -> β_o 永不因达成而触发。
402
+ 实测 (KeyDoor macro 宏执行): 成功率仅 14.4%, 即 85.6% 的 option
403
+ 执行到不了自己的目标。
404
+ """
405
+ if S is None or len(S) == 0:
406
+ return np.asarray(center, dtype=float)
407
+ d = np.linalg.norm(S - np.asarray(center, dtype=float)[None, :], axis=1)
408
+ return S[int(np.argmin(d))].astype(float)
409
+
410
+ @staticmethod
411
+ def _articulation_points(adj, n):
412
+ """Tarjan 割点检测 (**无向化**后的区域图)。
413
+
414
+ ★ 为什么不用介数中心性: 走廊/咽喉的语义是"去掉它图就断开",
415
+ 那是割点。介数中心性衡量的是"最短路径经过次数", 二者不等价 ——
416
+ 实测 (选项验证技能 FM-17, 四房间 gridworld): 介数最高的格
417
+ **不是**走廊格。
418
+ """
419
+ und = {r: set() for r in range(n)}
420
+ for r, outs in adj.items():
421
+ for e in outs:
422
+ nxt = e[0]
423
+ und[r].add(nxt)
424
+ und[nxt].add(r)
425
+
426
+ disc, low, parent = {}, {}, {}
427
+ aps = set()
428
+ timer = [0]
429
+
430
+ def dfs(u):
431
+ disc[u] = low[u] = timer[0]
432
+ timer[0] += 1
433
+ children = 0
434
+ for v in und.get(u, ()):
435
+ if v not in disc:
436
+ parent[v] = u
437
+ children += 1
438
+ dfs(v)
439
+ low[u] = min(low[u], low[v])
440
+ if u not in parent and children > 1:
441
+ aps.add(u) # 根: 多棵子树才是割点
442
+ if u in parent and low[v] >= disc[u]:
443
+ aps.add(u)
444
+ elif v != parent.get(u):
445
+ low[u] = min(low[u], disc[v])
446
+
447
+ for s in range(n):
448
+ if s not in disc:
449
+ dfs(s)
450
+ return aps
451
+
452
+ @staticmethod
453
+ def _bfs_action_path(adj, src, dst, max_len):
454
+ """在区域图上 BFS 出 src->dst 的**动作序列** (优先低不确定性边)。"""
455
+ from collections import deque
456
+ q = deque([(src, [])])
457
+ seen = {src}
458
+ while q:
459
+ cur, path = q.popleft()
460
+ if len(path) > max_len:
461
+ continue
462
+ if cur == dst and path:
463
+ return path
464
+ for (nxt, u, _un) in adj[cur]:
465
+ if nxt in seen:
466
+ continue
467
+ seen.add(nxt)
468
+ q.append((nxt, path + [int(u)]))
469
+ return None
470
+
471
+ def select(self, s, goal=None, exclude_unc=True, radius=None):
472
+ """选一个 option: 优先「起点匹配 + 目标最接近 goal + 成功率高」。
473
+
474
+ goal=None 时选成功率高且不确定性低的那个。
475
+ """
476
+ cands = []
477
+ # ★ 半径必须与状态空间尺度匹配。one-hot 状态空间里**两个不同状态**的
478
+ # L2 距离是 √2 ≈ 1.41, 固定 0.75 会让启动集几乎永不满足
479
+ # (实测: 主回路里 option 一次都没被选用, primitive 与 option 臂
480
+ # 给出**逐位相同**的数字)。
481
+ _r = radius if radius is not None else self.init_radius
482
+ for o in self.options:
483
+ if not o.initiable(s, radius=_r):
484
+ continue
485
+ # ★ 同 _reliable_paths 的坑: 用 `>` 而不是 `>=`。
486
+ # 确定性模型下 tau_U=0 且 uncs=[0.0], `>=` 会把**全部** option
487
+ # 过滤掉 -> 主回路里 option 一次都没被选用 -> primitive 与 option 臂
488
+ # 给出逐位相同的数字。
489
+ if exclude_unc and o.uncs and max(o.uncs) > getattr(self, "tau_U", np.inf) + 1e-12:
490
+ continue
491
+ rate = (o.success / o.visits) if o.visits else 0.5 # 先验乐观
492
+ score = rate - (float(np.mean(o.uncs)) if o.uncs else 0.0)
493
+ if goal is not None:
494
+ score -= float(np.linalg.norm(o.goal_center - np.asarray(goal)))
495
+ cands.append((score, o))
496
+ return max(cands, key=lambda t: t[0])[1] if cands else None
497
+
498
+ def stats(self):
499
+ return {"n_options": len(self.options),
500
+ "tau_U": round(float(getattr(self, "tau_U", float("nan"))), 6),
501
+ "n_edges": len(self.graph),
502
+ "options": [o.stats() for o in self.options[:6]]}
hibs_lnn/pln_head.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PLN 头 (lm1 V31_PLN 的可移植移植版) + OML 双循环原语。
2
+
3
+ 来源: tests/run_v31_meta_learning.py:114 V31_PLN / head_forward / per_feature_step
4
+ tests/run_lm3_bpe.py:193 oml2 双循环协议
5
+
6
+ lm1 原设计(见 run_lm1_production.py 文件头):
7
+ SSM (RLN) + OML 双层 (内循环 PLN 适应 + 外循环合并) + 价值提议器
8
+
9
+ 本模块只搬 RLN 之外的两件事:
10
+ 1. PLN: 小 MLP 头 + **per-feature 步长** (Meta-SGD / IDBD), 内循环只应用不修改 β
11
+ 2. 双循环协议: 内循环克隆头适应 support → 外循环用 query loss 更新全模型
12
+
13
+ 与 lm4 的结合点: lm4 原本是 `head = nn.Linear(d_model, n_classes)` 的**单循环**训练。
14
+ 把 head 换成 PLN 并改用双循环, 即 lm1 的架构。
15
+ """
16
+ import math
17
+ from typing import List
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+ from hibs_lnn.swifttd_head import SwiftTDHead
24
+
25
+
26
+ class PLNHead(nn.Module):
27
+ """预测学习网络 (Prediction Learning Network).
28
+
29
+ 结构与 V31_PLN 一致: Linear(in, d) → Tanh → Linear(d, out)
30
+ 差别: 输入是连续特征聚合 (lm4) 而非离散 hidden, 故 input_dim 可独立指定。
31
+
32
+ step_beta 是 **meta-parameter**: 外循环学它, 内循环只用它做
33
+ `W ← W - exp(beta) * grad`. 这样内循环有 per-feature 自适应步长,
34
+ 而不是所有参数共享一个标量 lr。
35
+ """
36
+
37
+ def __init__(self, input_dim, d_model, n_classes, inner_lr=0.1):
38
+ super().__init__()
39
+ self.input_dim = input_dim
40
+ self.d_model = d_model
41
+ self.n_classes = n_classes
42
+ self.predictor = nn.Sequential(
43
+ nn.Linear(input_dim, d_model),
44
+ nn.Tanh(),
45
+ nn.Linear(d_model, n_classes),
46
+ )
47
+ # log 空间初始化 → exp(beta) = inner_lr
48
+ self.step_beta = nn.Parameter(
49
+ torch.full((self._n_params(),), math.log(inner_lr)))
50
+
51
+ # ---------- 参数工具 ----------
52
+ def _n_params(self) -> int:
53
+ return sum(p.numel() for p in self.predictor.parameters())
54
+
55
+ def clone_params(self) -> List[torch.Tensor]:
56
+ """克隆可适应参数 (保留到共享参数的图, 供外循环反传)。"""
57
+ lin0 = self.predictor[0]
58
+ lin2 = self.predictor[2]
59
+ return [lin0.weight.clone(), lin0.bias.clone(),
60
+ lin2.weight.clone(), lin2.bias.clone()]
61
+
62
+ def per_feature_step(self, W: List[torch.Tensor],
63
+ grads: List[torch.Tensor]) -> List[torch.Tensor]:
64
+ """Meta-SGD 内循环更新: 逐参数自适应步长 (与 V31_PLN.per_feature_step 同式)。"""
65
+ beta = self.step_beta
66
+ new_W, off = [], 0
67
+ for w, g in zip(W, grads):
68
+ n = w.numel()
69
+ b = beta[off:off + n].view_as(w)
70
+ alpha = b.exp().clamp(max=1.0)
71
+ new_W.append(w - alpha * g)
72
+ off += n
73
+ return new_W
74
+
75
+ def load_cloned(self, W: List[torch.Tensor]) -> None:
76
+ """把克隆参数写回本体 (仅评估/合并用)。"""
77
+ with torch.no_grad():
78
+ self.predictor[0].weight.copy_(W[0])
79
+ self.predictor[0].bias.copy_(W[1])
80
+ self.predictor[2].weight.copy_(W[2])
81
+ self.predictor[2].bias.copy_(W[3])
82
+
83
+ # ---------- 前向 ----------
84
+ @staticmethod
85
+ def fwd_with(W: List[torch.Tensor], h: torch.Tensor) -> torch.Tensor:
86
+ """用给定的参数集做前向 (functional, 支持内循环克隆参数)。"""
87
+ w1, b1, w2, b2 = W
88
+ x = torch.tanh(h @ w1.t() + b1)
89
+ return x @ w2.t() + b2
90
+
91
+ def forward(self, h: torch.Tensor) -> torch.Tensor:
92
+ return self.predictor(h)
93
+
94
+
95
+ def inner_adapt(head: PLNHead, h_sup: torch.Tensor, y_sup: torch.Tensor,
96
+ K: int = 2, per_feature: bool = True):
97
+ """内循环: 在 support 上适应**克隆的**头参数, 返回适应后的 W。
98
+
99
+ per_feature=True → 用 head.step_beta (lm1 的 Meta-SGD 步长)
100
+ per_feature=False → 固定标量 lr (lm3 oml2 的做法, lr=0.1)
101
+
102
+ 注意: 整个过程对 head.predictor 的参数**保留梯度图** (没有 detach),
103
+ 这样外循环的 query loss 反传时, 梯度会经过内循环更新流回 meta-parameters。
104
+ """
105
+ W = head.clone_params()
106
+ for _ in range(K):
107
+ logits = PLNHead.fwd_with(W, h_sup)
108
+ loss = F.cross_entropy(logits, y_sup)
109
+ grads = torch.autograd.grad(loss, W, create_graph=True)
110
+ if per_feature:
111
+ W = head.per_feature_step(W, grads)
112
+ else:
113
+ W = [w - 0.1 * g for w, g in zip(W, grads)]
114
+ return W
115
+
116
+
117
+ def oml_step(model, head: PLNHead, enc, h_sup, y_sup, h_qry, y_qry,
118
+ opt_outer, K: int = 2, per_feature: bool = True,
119
+ consolidate: bool = True, reptile_lr: float = 0.0):
120
+ """一次 OML 双循环步。
121
+
122
+ 内循环: 克隆头在 support 上适应 K 步 (慢参数冻结)
123
+ 外循环: fast head 在 query 上的 loss → 反传更新 RLN + PLN meta 参数
124
+ 合并: 把适应后的 fast head 写回本体 (OML 权重整合)
125
+ Reptile: 再把本体 init 朝适应后权重拉一步 (lm1 meta_step 的 init 平均)
126
+ init ← init + reptile_lr * (W_adapted - init)
127
+
128
+ 返回 (query_loss, support_loss) 标量。
129
+ """
130
+ # --- 内循环 (RLN 冻结, 只适应头; 梯度图保留供外循环用) ---
131
+ W = inner_adapt(head, h_sup, y_sup, K=K, per_feature=per_feature)
132
+
133
+ # --- 外循环: query loss 反传全模型 ---
134
+ opt_outer.zero_grad()
135
+ logits_q = PLNHead.fwd_with(W, h_qry)
136
+ loss_q = F.cross_entropy(logits_q, y_qry)
137
+ loss_q.backward()
138
+ # 二阶 (create_graph) 梯度必须裁剪 —— lm1 meta_step 的 clip_grad_norm_(1.0)。
139
+ # 不裁剪时外循环会把编码器打爆, 表现为"当前域刚学会就整体归零"。
140
+ torch.nn.utils.clip_grad_norm_(
141
+ [p for p in model.parameters() if p.requires_grad], 1.0)
142
+ opt_outer.step()
143
+
144
+ # --- OML 权重整合: 适应后的头合并回本体 ---
145
+ if consolidate:
146
+ with torch.no_grad():
147
+ head.load_cloned([w.detach() for w in W])
148
+ # 合并后这些是叶子参数, 外循环下一步能继续更新
149
+ head.predictor[0].weight.requires_grad_(True)
150
+ head.predictor[0].bias.requires_grad_(True)
151
+ head.predictor[2].weight.requires_grad_(True)
152
+ head.predictor[2].bias.requires_grad_(True)
153
+
154
+ # --- Reptile init 平均 (lm1 V31.2 meta_step 的做法) ---
155
+ if reptile_lr > 0.0:
156
+ with torch.no_grad():
157
+ for p, w in zip(head.predictor.parameters(), W):
158
+ p.add_(reptile_lr * (w.detach() - p))
159
+
160
+ with torch.no_grad():
161
+ loss_s = F.cross_entropy(PLNHead.fwd_with(
162
+ [w.detach() for w in W], h_sup), y_sup).item()
163
+ return loss_q.item(), loss_s
164
+
165
+
166
+ # ============================================================
167
+ # SwiftTD 头 (lm1 hibs_lnn/swifttd_head.py 的适配器)
168
+ # ============================================================
169
+ class SwiftTDAdapter(nn.Module):
170
+ """把 lm1 的 SwiftTDHead 适配成分类头, 接口与 PLNHead 对齐。
171
+
172
+ SwiftTD 与 PLN 的本质差异:
173
+ PLN — 内循环用 Meta-SGD 梯度, step_beta 是 **meta 参数**, 外循环可学
174
+ SwiftTD — 内循环用**局部规则** (δ·φ), 每参数步长 θ 由 IDBD 在线自适应,
175
+ 外循环不需要学它 (θ 靠 bound/decay 自己调节)
176
+
177
+ 两者都提供 step_graph (可微版本), 所以都能直接插进 OML 双循环。
178
+ """
179
+
180
+ def __init__(self, input_dim, n_classes, beta_init=0.01, kappa=0.05,
181
+ eta=0.1, eps=0.99, tau_norm=None):
182
+ super().__init__()
183
+ self.input_dim = input_dim
184
+ self.n_classes = n_classes
185
+ self.head = SwiftTDHead(d_model=input_dim, world_vocab=n_classes,
186
+ n_resp=1, input_dim=input_dim,
187
+ beta_init=beta_init, kappa=kappa,
188
+ eta=eta, eps=eps, tau_norm=tau_norm)
189
+ s = 1.0 / math.sqrt(input_dim)
190
+ self.w1 = nn.Parameter(torch.randn(input_dim, input_dim) * s)
191
+ self.b1 = nn.Parameter(torch.zeros(input_dim))
192
+ self.w2 = nn.Parameter(torch.randn(n_classes, input_dim) * s)
193
+ self.b2 = nn.Parameter(torch.zeros(n_classes))
194
+
195
+ def to(self, *args, **kwargs):
196
+ super().to(*args, **kwargs)
197
+ self.head.to(self.w1.device)
198
+ return self
199
+
200
+ def clone_params(self):
201
+ return [self.w1.clone(), self.b1.clone(),
202
+ self.w2.clone(), self.b2.clone()]
203
+
204
+ def load_cloned(self, W):
205
+ with torch.no_grad():
206
+ self.w1.copy_(W[0]); self.b1.copy_(W[1])
207
+ self.w2.copy_(W[2]); self.b2.copy_(W[3])
208
+
209
+ @staticmethod
210
+ def fwd_with(W, h):
211
+ x = torch.tanh(h @ W[0].t() + W[1])
212
+ return x @ W[2].t() + W[3]
213
+
214
+ def forward(self, h):
215
+ return self.fwd_with([self.w1, self.b1, self.w2, self.b2], h)
216
+
217
+ def step_size_stats(self):
218
+ return self.head.step_size_stats()
219
+
220
+
221
+ def inner_adapt_swifttd(head: SwiftTDAdapter, h_sup, y_sup, K: int = 1):
222
+ """SwiftTD 内循环: 逐样本应用局部规则 (W 保持计算图, 供外循环 BPTT)。
223
+
224
+ SwiftTD 是**在线单样本**规则 (见 lm1 adapt_graph), 所以内循环按样本串行。
225
+ K 是在同一批 support 上扫的遍数。
226
+ """
227
+ W = head.clone_params()
228
+ B = h_sup.shape[0]
229
+ for _ in range(K):
230
+ for b in range(B):
231
+ hb = h_sup[b:b + 1]
232
+ x = torch.tanh(hb @ W[0].t() + W[1])
233
+ logits = (x @ W[2].t() + W[3]).view(1, 1, head.n_classes)
234
+ p = F.softmax(logits, dim=-1)
235
+ onehot = F.one_hot(y_sup[b:b + 1], head.n_classes).float()
236
+ errs = (p - onehot).reshape(-1)
237
+ W = head.head.step_graph(W, hb[0], x[0], errs)
238
+ return W
239
+
240
+
241
+ def oml_step_swifttd(model, head: SwiftTDAdapter, h_sup, y_sup, h_qry, y_qry,
242
+ opt_outer, K: int = 1, consolidate: bool = True,
243
+ reptile_lr: float = 0.0):
244
+ """SwiftTD 版 OML 双循环 (与 oml_step 同协议, 只换内循环规则)。"""
245
+ W = inner_adapt_swifttd(head, h_sup, y_sup, K=K)
246
+
247
+ opt_outer.zero_grad()
248
+ loss_q = F.cross_entropy(SwiftTDAdapter.fwd_with(W, h_qry), y_qry)
249
+ loss_q.backward()
250
+ torch.nn.utils.clip_grad_norm_(
251
+ [p for p in model.parameters() if p.requires_grad], 1.0)
252
+ opt_outer.step()
253
+
254
+ if consolidate:
255
+ head.load_cloned([w.detach() for w in W])
256
+ if reptile_lr > 0.0:
257
+ with torch.no_grad():
258
+ for p, w in zip(head.parameters(), W):
259
+ p.add_(reptile_lr * (w.detach() - p))
260
+
261
+ with torch.no_grad():
262
+ loss_s = F.cross_entropy(SwiftTDAdapter.fwd_with(
263
+ [w.detach() for w in W], h_sup), y_sup).item()
264
+ return loss_q.item(), loss_s
hibs_lnn/rl_proposer.py ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """rl_proposer.py — 真正的 RL 提议器 (取代固定公式的 RegimeProposer)。
2
+
3
+ ## 为什么必须有这个文件
4
+
5
+ `RegimeProposer` 是个**启发式排序器**: V(p) 是状态的纯函数, 权重 λ 是超参,
6
+ **从不被 reward 更新, 也从不积累**。leo 连续两问戳中了它:
7
+ 「有把 policy 当做一个可以迭代和积累的参数来更新吗, 积累过程是什么?」
8
+ 「步长为主导的 policy 积累, redefine 也需要探索 policy 的积累」
9
+ 答案都是"没有"。本文件把闭环补成真 RL。
10
+
11
+ ## 范式: 动作 → 环境 → 奖励 → 参数更新
12
+
13
+ 环境 (Environment) = 训练回路本身: 选一个物理区间去训练 -> 模型变化 -> 能力画像变化
14
+ 状态 s_t = 当前能力画像 (每候选的实测 acc / 覆盖次数 / 已学标志 + 全局统计)
15
+ 动作 a_t = 选一个候选区间 (后续可扩展到"动作=连续控制量")
16
+ 奖励 r_t = **目标函数的增量** Δ(any-time 平均准确率)
17
+ ← 必须是真正要的东西, 不是"我这个区间考了多少分"
18
+ 参数更新 = θ ← θ + α_i ⊙ (r − V_θ) ∇, α_i 由 IDBD 在线元学
19
+
20
+ π_θ(a|s) = softmax( V_θ(s,a) / τ ), V_θ(s,a) = θ · φ(s,a)
21
+
22
+ ## 步长为主导的积累 (OaK 第②条 / Sutton IDBD)
23
+
24
+ 关键: **α_i 不是超参, 而是随经验累积的状态**。
25
+
26
+ δ_t = r_t − V_θ(s_t, a_t) (TD/bandit 误差)
27
+ w_i += α_i · δ_t · φ_i (权重)
28
+ h_i = h_i · max(0, 1 − α_i · φ_i²) + α_i · φ_i · δ_t (★ 累积迹)
29
+ α_i = α_i · exp( μ · δ_t · φ_i · h_i ) (★ 步长本身累积)
30
+
31
+ `h_i` 是"信用迹", 把历史 δ 与特征的相关性**累加**起来; `α_i` 按它调整。
32
+ **这两条是把经验持续累积进参数的通路** —— 不是 EMA 式的追踪(那会主动遗忘),
33
+ 而是 Sutton 意义上的增量累积。
34
+
35
+ ## ★ 实测: 步长分化存在尖锐阈值 (与 V35.2/3 同病, 两处复现)
36
+
37
+ alpha0 = 0.01 -> alpha_std ~ 1e-6 **步长死亡** (仍是超参)
38
+ alpha0 = 0.05 -> alpha_std ~ 1e-4 **死亡**
39
+ alpha0 = 0.20 -> alpha_std ~ 0.3 **分化成立** (max/min ~5x)
40
+ mu=1.0 + alpha0=0.2 -> exp 溢出, ratio 1e5 = 发散 (已加指数截断)
41
+
42
+ 根因: IDBD 的 meta-梯度量级 ∝ alpha0 (见 V35.2/3: beta_init=0.001 推不动,
43
+ 0.05 才分化)。默认取稳定窗口 mu=0.05 / alpha0=0.2。
44
+ **注意**: 这只证明"步长分化了", 不证明"分化带来收益" —— 后者必须在
45
+ reward 真正依赖动作的回路里测。
46
+
47
+ ## 探索 policy 的积累 (redefine 存活)
48
+
49
+ explore[c] = {n_visits, mean_return, last_seen}
50
+ 探索加成 u(c) = 1/√(1 + n_visits) (UCB 风格, 递减但不归零)
51
+
52
+ **redefine 时不清零**: 候选池重建后, 每个新候选按**物理描述子最近的旧候选**
53
+ 继承 explore / α / θ 对应分量。这样"某个物理区域值不值得探索"这条知识
54
+ **跨 pool 重建存活** —— 这正是 leo 说的"redefine 也需要探索 policy 的积累"。
55
+
56
+ ## 与 RegimeProposer 的关系 (共存, 不是替换)
57
+
58
+ RegimeProposer 手工 V + 状态更新 -> 作为**基线**
59
+ RLProposer 可学 V_θ + IDBD 步长 -> 真 RL, 用来回答"学习有没有用"
60
+ """
61
+ from __future__ import annotations
62
+
63
+ import numpy as np
64
+
65
+
66
+ class RLProposer:
67
+ """动作→环境→奖励→参数更新 的提议器; 步长 (IDBD) 是主导的累积参数。
68
+
69
+ 参数
70
+ ----
71
+ desc : (N, D) 每个候选的物理描述子 (如 [log10 密度, log10 偶极 L, sin 磁纬])
72
+ lam : 兼容旧接口留空
73
+ tau : softmax 温度
74
+ k : 每次提议几个
75
+ mu : IDBD 元步长 (步长自身的更新率), 默认 0.01
76
+ alpha0 : 每个权重的初始步长
77
+ gamma : 折扣; 1.0 = bandit (无 bootstrap), <1 = TD
78
+ explore_w : 探索加成权重 (UCB 风格)
79
+ seed : 随机种子
80
+ """
81
+
82
+ def __init__(self, desc, tau=0.5, k=2, mu=0.05, alpha0=0.2,
83
+ gamma=1.0, explore_w=0.5, seed=0, algo="cidbd",
84
+ auto_scope="global", n_know=0):
85
+ desc = np.asarray(desc, dtype=np.float64)
86
+ self.n, self.d = desc.shape
87
+ mu_d = desc.mean(0)
88
+ sd_d = desc.std(0)
89
+ sd_d[sd_d < 1e-9] = 1.0
90
+ self.desc = desc
91
+ self.z = (desc - mu_d) / sd_d # 归一化描述子 (用于 redefine 继承)
92
+ self.tau, self.k = float(tau), int(k)
93
+ self.mu, self.gamma, self.explore_w = float(mu), float(gamma), float(explore_w)
94
+ self.rng = np.random.default_rng(seed)
95
+
96
+ # ── 可学参数 (policy 本身) ──
97
+ # 特征 φ(s,a) = [1, z(a), 全局统计×z(a), 覆盖, 探索加成]
98
+ # ★ 「丰富的内部知识」(Horde/GVF 思路): 智能体**自己的预测**进入状态。
99
+ # 手写先验 φ = [1, z(a), z(a)*any_time, cov, unc] 只有 9 维, 且每块都是
100
+ # 人给定的结构 (见 docs/oak_alignment.md §"人为规定最少的结构")。
101
+ # n_know > 0 时, 评测方每步可通过 set_knowledge() 注入一段**外部知识向量**
102
+ # (例如: 当前能力画像 acc_matrix 的一行 + 转移模型对每个候选的预测增量),
103
+ # 它被线性映射进 φ。知识维度越高, 可分化步长的方向就越多。
104
+ self.n_know = int(n_know)
105
+ self.fdim = 1 + self.d + self.d + 2 + self.n_know
106
+ self.theta = np.zeros(self.fdim) # 价值权重 (policy 参数)
107
+ # ★ 每个权重一个步长 + 一个信用迹 —— 这就是"步长为主导的积累"
108
+ self.alpha = np.full(self.fdim, float(alpha0))
109
+ self.beta = np.log(np.maximum(self.alpha, 1e-12))
110
+ self.h = np.zeros(self.fdim) # IDBD 信用迹 (累积)
111
+ self.baseline = 0.0
112
+ # ★ δ 的 RMS (误差归一化)。标准 IDBD 假设 δ 是 O(1) 量级, 但
113
+ # "目标函数的增量" δ≈0.006 -> mu*δ*phi*h ≈ 1e-6 -> 步长几乎不动
114
+ # (实测 alpha_std=0.0, 与 V35.2/3 记录的 beta_std=1.6e-05 同病)。
115
+ # 归一化后 mu 的含义与任务量级解耦。
116
+ self.drms = 1.0
117
+ # ★ Autostep (Mahmood/Sutton/Degris/Pilarski 2012) 的两个归一化器。
118
+ # 动机 (论文的单位分析): IDBD 的指数项 delta*x*h 量纲是 y^2, 所以
119
+ # 最优 mu 的量纲是 1/y^2 —— **目标方差一变, 最优 mu 就跨数量级漂移**
120
+ # (论文实测跨 5 个数量级)。我们的 reward = Δ(any-time) ~ 0.02, y^2 极小,
121
+ # 所以实测必须 alpha0>=0.2 才活、mu=1.0 就炸 —— 与论文所述完全一致。
122
+ # Autostep 的两步归一化让 mu 免调参、且"overshoot 不可能发生"。
123
+ self.v = np.zeros(self.fdim) # (4) |delta*x*h| 的 running max
124
+ self.v_g = 0.0 # global 作用域时的标量归一化因子
125
+ # ★ IDBD 量纲标准化器: 指数 delta*x*h 的量纲是 y^2 (论文 §3), 故须除以其
126
+ # 自身量级才能让 mu 与任务无关。**注意这不同于 Autostep**: Autostep 除以
127
+ # running **max** (把指数卡死在 mu, 杀掉积累); 这里除以 running **RMS**
128
+ # (慢变标量, 保留跨分量比例与累积性)。
129
+ self.grms = 1e-8
130
+ self.beta_lo = float(np.log(1e-6)) # log(alpha) 的下界 (限制累积总量)
131
+ self.beta_hi = float(np.log(10.0)) # 上界
132
+ self._warm = 0 # idbd-acc 的 warmup 计数
133
+ # ── Continual-IDBD (按用户给的 §9/§10 设计) ──
134
+ # s_i : 逐分量 |g_i| 的 EMA 尺度估计 (既归一化又保留跨分量比例)
135
+ # alpha_min/max : **clip 在 alpha 上**, beta 不 clip -> beta 可继续
136
+ # 累积, 也就能在恢复时立刻回到可塑区间
137
+ # E_base / kappa : 误差基线 + 变化检测阈值 (环境突变 -> 提可塑性)
138
+ self.s = np.ones(self.fdim) * 1e-8
139
+ self.rho = 0.01 # s 的 EMA 速率
140
+ self.alpha_min = 1e-5
141
+ self.alpha_max = 10.0
142
+ self.E_base = None
143
+ self.kappa = 3.0 # 误差 > kappa*基线 = 环境变了
144
+ self.alpha_recovery = 0.05 # 恢复时的可塑性下限
145
+ self.n_recovery = 0
146
+ self.flip_rate = [] # weight-flipping rate 的历史
147
+ self.know = np.zeros(self.n_know) # 内部知识向量 (评测方每步注入)
148
+ self.Wk = np.zeros((self.n_know, self.n_know)) # 知识->特征 的线性映射
149
+ if self.n_know:
150
+ # 初始化为确定性正交映射 (无需训练, 保留信息不塌缩)
151
+ self.Wk = np.eye(self.n_know) * (1.0 / max(1.0, np.sqrt(self.n_know)))
152
+ self.tau_a = 1e4 # (4) 归一化器的跟踪速率
153
+ self.algo = algo
154
+ # ★ Autostep 归一化因子的作用域。
155
+ # 论文 Table 1 用**逐分量** running max: v_i = max|delta*x_i*h_i|。
156
+ # 实测 (tests/diag_stepsize.py): 逐分量归一化把指数**卡死在 mu**
157
+ # (|e|max 恰 = mu), 于是跨分量的量级信息被抹掉 -> alpha max/min
158
+ # 只有 1.10/1.31, 而 h 自身的 max/min 高达 72/55 (特征明明可分)。
159
+ # 论文 §3 的单位分析给出正确作用域: delta*x*h 的量纲是 y^2, 故
160
+ # 归一化因子应是一个**带 y^2 量纲的标量**, 而非逐分量向量。
161
+ # 'global' = 用全局 running max (保留跨分量比例, 默认);
162
+ # 'per_component' = 论文原版 (保留以作对照)。
163
+ self.auto_scope = auto_scope
164
+
165
+ # ── 环境状态 ──
166
+ self.visits = np.zeros(self.n, dtype=np.float64)
167
+ self.acc = np.full(self.n, np.nan)
168
+ self.learned = np.zeros(self.n, dtype=bool)
169
+ self.global_acc = 0.0 # 上一轮的全域平均 (算 reward 用)
170
+ self.any_time = 0.0
171
+
172
+ # ── 探索记录 (redefine 存���) ──
173
+ self.explore = np.zeros(self.n, dtype=np.float64) # 每候选的累积收益
174
+ self.explore_n = np.zeros(self.n, dtype=np.float64)
175
+ self.redefine_count = 0
176
+ self.trace = [] # 每步记录, 供分析
177
+
178
+ # ─────────────── 特征 / 价值 / 策略 ───────────────
179
+ def set_knowledge(self, vec):
180
+ """注入内部知识向量 (Horde/GVF: 让智能体自己的预测成为状态)。
181
+
182
+ 调用方每步喂入, 例如:
183
+ [acc_matrix[t] 的一行 (6 域能力画像), 之前几步的 any-time, 遗忘信号]
184
+ 或 转移模型对每个候选的预测增量。
185
+ 形状不匹配则忽略 (保证向后兼容)。
186
+ """
187
+ v = np.asarray(vec, dtype=float).ravel()
188
+ if v.size != self.n_know:
189
+ return
190
+ self.know = np.nan_to_num(v, nan=0.0, posinf=0.0, neginf=0.0)
191
+
192
+ def phi(self, i):
193
+ """φ(s,a): 描述子 + 与全局状态的调制 + 覆盖/探索加成 + **内部知识**。"""
194
+ cov = 1.0 / (1.0 + self.visits[i])
195
+ unc = 1.0 / np.sqrt(1.0 + self.explore_n[i]) # UCB 风格探索加成
196
+ zz = self.z[i]
197
+ base = np.concatenate([[1.0], zz, zz * self.any_time, [cov, unc]])
198
+ if self.n_know:
199
+ # 知识以 zz 为门控投影进来: 候选特定的知识 vs 全局知识
200
+ k = self.Wk @ self.know
201
+ return np.concatenate([base, k * (1.0 + 0.5 * zz[0])])
202
+ return base
203
+
204
+ def value(self, i):
205
+ return float(self.theta @ self.phi(i))
206
+
207
+ def score(self, i):
208
+ return self.value(i) + self.explore_w / np.sqrt(1.0 + self.explore_n[i])
209
+
210
+ def probs(self):
211
+ # ★ 必须 clip: theta 学起来后分数差会拉大, 直接 exp 会让远端候选
212
+ # **下溢成恰好 0**, 于是 rng.choice(replace=False) 报
213
+ # "Fewer non-zero entries in p than size" 而崩。
214
+ # clip 到 [-30, 0] 保证每个候选都有非零概率 (exp(-30) ~ 9e-14)。
215
+ s = np.array([self.score(i) for i in range(self.n)])
216
+ s = np.clip((s - s.max()) / max(1e-9, self.tau), -30.0, 0.0)
217
+ e = np.exp(s)
218
+ tot = e.sum()
219
+ if tot <= 0 or not np.isfinite(tot):
220
+ return np.full(self.n, 1.0 / self.n) # 兜底: 退化成均匀
221
+ return e / tot
222
+
223
+ # ─────────────── 动作 ───────────────
224
+ def act(self, k=None, greedy=False):
225
+ """选动作 (提议 k 个候选)。greedy=True 用 argmax (评估用)。"""
226
+ k = k or self.k
227
+ if greedy:
228
+ order = np.argsort([self.score(i) for i in range(self.n)])[::-1]
229
+ idx = list(map(int, order[:k]))
230
+ else:
231
+ p = self.probs()
232
+ idx = list(map(int, self.rng.choice(self.n, size=min(k, self.n),
233
+ replace=False, p=p)))
234
+ for i in idx:
235
+ self.visits[i] += 1
236
+ return idx
237
+
238
+ # ─────────────── 奖励 → 参数更新 ───────────────
239
+ def observe(self, idx, acc, any_time=None):
240
+ """环境反馈: 记录实测 acc + 全域 any-time 准确率。
241
+
242
+ reward 由 update() 从**目标函数的增量**算出, 不是"该区间考了多少分"。
243
+ """
244
+ for i in idx:
245
+ self.acc[i] = float(acc)
246
+ self.learned[i] = True
247
+ if any_time is not None:
248
+ self.any_time = float(any_time)
249
+
250
+ def update(self, idx, r=None, gamma=None):
251
+ """★ 参数更新: 用 reward 更新 policy。返回本步的 δ。
252
+
253
+ r 为空时用 Δ(any-time) 作为 reward —— 这才是**真正的目标**。
254
+ """
255
+ gamma = self.gamma if gamma is None else gamma
256
+ if r is None:
257
+ r = self.any_time - self.global_acc # 目标函数增量
258
+ self.global_acc = self.any_time
259
+
260
+ deltas = []
261
+ for i in idx:
262
+ phi = self.phi(i)
263
+ v = float(self.theta @ phi)
264
+ delta = float(r - v) # δ = r − V
265
+ # ★ 误差归一化: 把 δ 缩放到 ~O(1), 否则 IDBD 的 meta-梯度消失
266
+ self.drms = 0.99 * self.drms + 0.01 * delta ** 2
267
+ delta_n = delta / max(1e-6, np.sqrt(self.drms))
268
+ if self.algo == "autostep":
269
+ # ── Autostep (2012 Table 1) ──
270
+ g = np.abs(delta_n * phi * self.h)
271
+ if self.auto_scope == "global":
272
+ # ★ 标量归一化 (单位分析推导的修正): 保留跨分量比例
273
+ gmax = float(g.max()) if g.size else 0.0
274
+ self.v_g = max(gmax, self.v_g + (1.0 / self.tau_a)
275
+ * float(np.sum(self.alpha * phi ** 2))
276
+ * (gmax - self.v_g))
277
+ denom = self.v_g if self.v_g > 0 else 1.0
278
+ self.alpha *= np.exp(
279
+ np.clip(self.mu * delta_n * phi * self.h / denom, -2.0, 2.0))
280
+ else:
281
+ # 论文原版: 逐分量 running max (会把 |e| 卡死在 mu)
282
+ self.v = np.maximum(g, self.v + (1.0 / self.tau_a)
283
+ * self.alpha * phi ** 2 * (g - self.v))
284
+ nz = self.v > 0
285
+ self.alpha[nz] *= np.exp(
286
+ np.clip(self.mu * delta_n * phi[nz] * self.h[nz]
287
+ / self.v[nz], -2.0, 2.0))
288
+ # (6)(7) 有效步长上界: sum(alpha*x^2) <= 1 => 过冲不可能
289
+ M = max(float(np.sum(self.alpha * phi ** 2)), 1.0)
290
+ self.alpha = self.alpha / M
291
+ # 权重 + 信用迹 (与 IDBD 同)
292
+ self.theta += self.alpha * delta_n * phi
293
+ self.h = self.h * np.maximum(0.0, 1.0 - self.alpha * phi ** 2) \
294
+ + self.alpha * phi * delta_n
295
+ else:
296
+ # ── IDBD (Sutton 1992, 官方式 (3)) —— 累积型 + 三项安全化 ──
297
+ # 官方: alpha *= exp(mu * delta * x * h) (指数项**带 x**)
298
+ #
299
+ # 三处安全化, 每处都刻意**保住累积性** (与 Autostep 的取舍相反):
300
+ # ① 量纲标准化: 指数除以 |delta*x*h| 的 running **RMS**。
301
+ # Autostep 用 running max -> 指数恒被卡在 mu, n 从 36 到 1000
302
+ # ratio 恒为 1.01 (实测) = 完全不累积。RMS 是慢变标量,
303
+ # 跨分量比例与时间累积都保留。
304
+ # ② 累积量限幅: 限制的**不是每步指数**, 而是累积后的 log(alpha)
305
+ # 本身 (beta in [log 1e-6, log 10])。Autostep 限制每步指数,
306
+ # 于是总位移被 exp(mu*n) 封顶; 这里允许单步大步, 只防跑飞。
307
+ # ③ 地板可恢复: 官方 IDBD 会把握信噪比差的分量打到下限 (实测出现
308
+ # 1e-5 且再也回不来)。这里对撞到地板的分量给一个微小的复活推进。
309
+ self.theta += self.alpha * delta_n * phi
310
+ self.h = self.h * np.maximum(0.0, 1.0 - self.alpha * phi ** 2) \
311
+ + self.alpha * phi * delta_n
312
+ _raw = delta_n * phi * self.h
313
+ if self.algo == "cidbd":
314
+ # ── Continual-IDBD (用户给的 §9 设计) ──
315
+ # g_i = delta*x_i*h_i (元梯度)
316
+ # s_i <- (1-rho)s_i + rho*|g_i| (逐分量 EMA 尺度)
317
+ # beta_i <- beta_i + mu * g_i/(s_i+eps) (归一化后累积)
318
+ # alpha_i = clip(exp(beta_i), a_min, a_max) (**clip 在 alpha 上**,
319
+ # beta 不 clip)
320
+ # h_i <- h_i[1-alpha_i x_i^2]^+ + alpha_i delta x_i
321
+ # 与 Autostep 的关键差别: 用 EMA 而非 running max。
322
+ # max 会把指数**卡死在 mu**(实测 |e|max ≡ mu, n 从 36 到 1000
323
+ # 完全不累积); EMA 是平滑估计, 保留跨分量比例与时间累积。
324
+ self.s = (1.0 - self.rho) * self.s + self.rho * np.abs(_raw)
325
+ _e = self.mu * _raw / (self.s + 1e-12)
326
+ _e = np.clip(_e, -2.0, 2.0)
327
+ self.beta = np.log(np.maximum(self.alpha, 1e-12)) + _e
328
+ self.alpha = np.clip(np.exp(np.clip(self.beta, -30, 30)),
329
+ self.alpha_min, self.alpha_max)
330
+ self.flip_rate.append(float(np.mean(
331
+ np.sign(_e) != np.sign(getattr(self, "_e_prev", _e)))))
332
+ self._e_prev = _e
333
+ elif self.algo == "idbd-raw":
334
+ # ★ 官方 IDBD, **不做任何 running 归一化**。
335
+ # 实测 (真实回路): raw 版 alpha_std=0.2357 / ratio=3.92;
336
+ # 而加 RMS 归一化后掉到 0.0006 / 1.01 —— 因为 delta 已被
337
+ # delta_rms 归一化过一次, 再除 grad_rms 等于**双重归一化**,
338
+ # 指数塌到 ~0, alpha 几乎不动。分化能力被自己的"安全化"杀死。
339
+ # 代价: 指数无界 -> 靠下面的软壁垒与地板兜底。
340
+ _e = np.clip(self.mu * _raw, -2.0, 2.0)
341
+ elif self.algo == "idbd-acc":
342
+ # ★ 真累积: 归一化因子在**前 warmup 步测一次就冻结**。
343
+ # 任何 running 归一化 (Autostep 的 max / 上面的 RMS) 都会把
344
+ # 指数变成平稳量 -> n 从 36 到 1000 完全不累积 (实测 ratio
345
+ # 恒在 ~40x 不增长)。要在时间上累积, 归一化因子必须是常数。
346
+ # 代价: 该常数与任务量级���合 -> 用 warmup 自动标定一次。
347
+ if self._warm < 50:
348
+ self._warm += 1
349
+ self.grms = max(self.grms, float(np.mean(_raw ** 2)))
350
+ _den = max(1e-8, np.sqrt(self.grms))
351
+ else:
352
+ _den = max(1e-8, np.sqrt(self.grms)) # 冻结, 不再更新
353
+ _e = np.clip(self.mu * _raw / _den, -2.0, 2.0)
354
+ else:
355
+ self.grms = 0.99 * self.grms + 0.01 * float(np.mean(_raw ** 2))
356
+ _e = np.clip(self.mu * _raw / max(1e-8, np.sqrt(self.grms)),
357
+ -2.0, 2.0)
358
+ # ★ 软壁垒, 不用硬 clip。
359
+ # 硬 clip 实测的失败: 所有分量的 log(alpha) 都顶到 beta_hi
360
+ # -> 挤在天花板上 -> alpha max/min 塌回 1.0 (分化全丢)。
361
+ # 软壁垒: 接近上界时只阻尼**正向**更新、接近下界时只阻尼
362
+ # **负向**更新。边界因此是「吸引但非吸收」的, 跨分量差异得以保留,
363
+ # 同时仍然防跑飞。
364
+ _la = np.log(self.alpha) + _e
365
+ _span = self.beta_hi - self.beta_lo
366
+ _t = np.clip((_la - self.beta_lo) / _span, 0.0, 1.0) # 0=下界 1=上界
367
+ _la = np.log(self.alpha) + _e * np.where(_e > 0, 1.0 - _t, _t)
368
+ _la = np.clip(_la, self.beta_lo, self.beta_hi) # 极值兜底
369
+ self.alpha = np.exp(_la)
370
+ # ③ 地板复活: 撞到下限的分量获得一个朝向可学区间的微小推进
371
+ _dead = self.alpha <= 1e-6 * 1.01
372
+ if _dead.any():
373
+ self.alpha[_dead] = np.minimum(
374
+ 10.0, self.alpha[_dead] * np.exp(0.05 * np.sign(_e[_dead])))
375
+ # (4) 探索记录累积 (redefine 存活)
376
+ self.explore[i] += r
377
+ self.explore_n[i] += 1.0
378
+ deltas.append(delta)
379
+
380
+ # ★ §10 reset/recovery: 误差超阈值 = 环境变了 -> **保留 w**, 只提可塑性。
381
+ # 这是「真正的持续学习」: 不是重训丢知识, 而是提高 plasticity 重新适应。
382
+ if self.algo == "cidbd":
383
+ _E = float(np.mean([d ** 2 for d in deltas])) if deltas else 0.0
384
+ if self.E_base is None:
385
+ self.E_base = max(_E, 1e-9)
386
+ elif _E > self.kappa * self.E_base:
387
+ # 只抬高 alpha (beta 同步), 权重 theta 完全不动
388
+ _new = np.maximum(self.alpha, self.alpha_recovery)
389
+ self.alpha = _new
390
+ self.beta = np.log(np.maximum(_new, 1e-12))
391
+ self.n_recovery += 1
392
+ else:
393
+ self.E_base = 0.999 * self.E_base + 0.001 * _E
394
+ self.baseline += 0.05 * (r - self.baseline) # 简单基线
395
+ self.trace.append({
396
+ "idx": list(map(int, idx)), "reward": round(float(r), 5),
397
+ "delta": round(float(np.mean(deltas)) if deltas else 0.0, 5),
398
+ "alpha_mean": round(float(self.alpha.mean()), 6),
399
+ "alpha_std": round(float(self.alpha.std()), 6),
400
+ "h_norm": round(float(np.linalg.norm(self.h)), 5),
401
+ })
402
+ return float(np.mean(deltas)) if deltas else 0.0
403
+
404
+ # ─────────────── redefine: 探索积累跨重建存活 ───────────────
405
+ def redefine(self, new_desc):
406
+ """重建候选池, 但**继承**探索记录 / 步长 / 价值参数。
407
+
408
+ leo: "redefine 也需要探索 policy 的积累" —— 若重建就清零,
409
+ 则每次 redefine 都从零开始学"哪里值得探索", 探索知识无法累积。
410
+ 继承方式: 每个新候选按**物理描述子最近**的旧候选继承。
411
+ """
412
+ new_desc = np.asarray(new_desc, dtype=np.float64)
413
+ # 归一化用新池自己的统计 (与构造时一致)
414
+ mu_d, sd_d = new_desc.mean(0), new_desc.std(0)
415
+ sd_d[sd_d < 1e-9] = 1.0
416
+ zn = (new_desc - mu_d) / sd_d
417
+
418
+ keep = {"explore": [], "explore_n": [], "acc": [], "learned": []}
419
+ for a in zn:
420
+ j = int(np.argmin(np.linalg.norm(self.z - a, axis=1))) # 最近旧候选
421
+ keep["explore"].append(self.explore[j])
422
+ keep["explore_n"].append(self.explore_n[j])
423
+ keep["acc"].append(self.acc[j])
424
+ keep["learned"].append(bool(self.learned[j]))
425
+
426
+ self.n = len(new_desc)
427
+ self.desc, self.z = new_desc, zn
428
+ self.explore = np.array(keep["explore"])
429
+ self.explore_n = np.array(keep["explore_n"])
430
+ self.acc = np.array(keep["acc"])
431
+ self.learned = np.array(keep["learned"])
432
+ self.visits = np.maximum(1.0, self.explore_n) # 已探过的别当全新的
433
+ self.redefine_count += 1
434
+ # θ / α / h 是全局参数, 天然跨 redefine 存活 —— 这正是"积累"
435
+
436
+ # ─────────────── 诊断 ───────────────
437
+ def stats(self):
438
+ return {
439
+ "algo": self.algo,
440
+ "n_candidates": int(self.n),
441
+ "n_learned": int(self.learned.sum()),
442
+ "redefine_count": int(self.redefine_count),
443
+ # ★ 步长是核心累积量: 它的分化程度 = 是否真的在学"谁该快谁该慢"
444
+ "alpha": {
445
+ "min": round(float(self.alpha.min()), 6),
446
+ "max": round(float(self.alpha.max()), 6),
447
+ "std": round(float(self.alpha.std()), 6),
448
+ "mean": round(float(self.alpha.mean()), 6),
449
+ "ratio_max_min": round(float(self.alpha.max()
450
+ / max(1e-9, self.alpha.min())), 2),
451
+ },
452
+ "h_norm": round(float(np.linalg.norm(self.h)), 5),
453
+ "theta_norm": round(float(np.linalg.norm(self.theta)), 5),
454
+ "baseline": round(float(self.baseline), 5),
455
+ "explore": {"nonzero": int((self.explore_n > 0).sum()),
456
+ "mean_n": round(float(self.explore_n.mean()), 3)},
457
+ "any_time": round(float(self.any_time), 4),
458
+ "delta_rms": round(float(np.sqrt(max(0.0, self.drms))), 6),
459
+ "grad_rms": round(float(np.sqrt(max(0.0, self.grms))), 6),
460
+ "alpha_dead": int((self.alpha <= 1e-5).sum()),
461
+ "n_recovery": int(getattr(self, "n_recovery", 0)),
462
+ "flip_rate_mean": round(float(np.mean(self.flip_rate[-50:]))
463
+ if getattr(self, "flip_rate", None) else 0.0, 4),
464
+ }
hibs_lnn/rr_agent.py ADDED
@@ -0,0 +1,1108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """`RRSkillAgent` —— OaK 第一步(SubTask → Option)在 KeyDoor 上的 agent 侧实现。
3
+
4
+ **为什么是子类**:`SkillAgent.run_episode` 是所有实验臂的公共路径。把 rr 逻辑
5
+ 写进父类会改动正在跑的管线;写成子类则:
6
+
7
+ * `mode != "rr"` 时**逐字委托** `super().run_episode()` → primitive /
8
+ rediscover / macro 三个对照臂与原来**逐位相同**(对照干净);
9
+ * 部署只是**新增一个文件**, 不修改任何现有文件。
10
+
11
+ ## 与 OpenLoop 的区别(§1, Pitfall 35)
12
+
13
+ a_t = π_o(s_t) ← **每一步都按当前状态重算**(本文件)
14
+ a_{t+i} = a_i^o ← 固定动作序列replay(不是时间抽象)
15
+
16
+ `π_o` 来自子任务的价值迭代解,存成表;执行时**每个状态查一次表**, 所以
17
+ `recompute-count == 执行步数`。
18
+
19
+ ## 四条终止判据(§6, 全部必需)
20
+
21
+ ① goal_reached 特征达成(x_i(s)=1) ← 子任务的**目的**
22
+ ② beta_stop 表中 β_o(s)=True(停车最优)
23
+ ③ expired **硬到期**(由 option 自身的名义时长导出, 不是拍脑袋常数)
24
+ ④ stall 连续 stall_patience 步状态不变(撞墙空转)
25
+
26
+ 另有 ⑤ devalued: 主任务价值追上后增益 ≤ 0 → 主动放弃。
27
+
28
+ 到期是**独立的第四条**而不是前三条的兜底 —— 前三条各自会因自己的原因不触发
29
+ (容差尺度错 / 台账空 / 阈值没到), 到期把损害封顶。
30
+ """
31
+ import numpy as np
32
+ import math
33
+
34
+ from hibs_lnn.rr_options import (
35
+ KeyDoorTabular, build_bottleneck_library, build_random_goal_library,
36
+ build_rr_library, initiation_ok, main_value, option_gain,
37
+ )
38
+ from hibs_lnn.skill_agent import SkillAgent
39
+ from hibs_lnn.subtask_options import STOP
40
+
41
+
42
+ class RRSkillAgent(SkillAgent):
43
+ """在 `SkillAgent` 上增加 `mode="rr"`(reward-respecting subtask options)。
44
+
45
+ 消融旋钮(每个对应矩阵里的一臂):
46
+ rr_v_main "exact" | "learned" 价值函数来源(精确 VI / agent 自己的 Q)
47
+ rr_z_mode "rr" | "zeroV" | "nobonus"
48
+ rr_cumulant "reward" | "zero"
49
+ rr_feats ("key","door") | ("key",) ...
50
+ rr_bonus w̄(浮点, 扫录用)
51
+ rr_lib "rr" | "bottleneck" | "random" 换整库(负对照)
52
+ """
53
+
54
+ def __init__(self, mdp, mode="rr", seed=0, rr_v_main="exact",
55
+ rr_z_mode="rr", rr_cumulant="reward", rr_feats=("key", "door"),
56
+ rr_bonus=1.0, rr_lib="rr", rr_max_duration=None,
57
+ stall_patience=4, rr_select_rule="uniform",
58
+ rr_min_launches=6, rr_ucb_c=1.0, rr_calib_min_rate=0.5,
59
+ rr_persist=True, rr_forget_hl=float("inf"),
60
+ rr_score="rate", rr_pot="learned",
61
+ rr_calib_min_adv=0.0, rr_retire_rule="internal",
62
+ rr_calib_min_advq=0.0, rr_forgive_p=0.0,
63
+ rr_lr_v=0.2, rr_starve_steps=None, rr_unk_p=0.5,
64
+ rr_three_state=False, rr_dyn="none", rr_dyn_c=1.0,
65
+ rr_evidence=False, rr_ev_n_min=10, rr_ev_z_bad=2.0,
66
+ rr_ev_stale_hl=200, **kw):
67
+ super().__init__(mdp, mode=mode, seed=seed, **kw)
68
+ self.rr_v_main = rr_v_main
69
+ self.rr_z_mode = rr_z_mode
70
+ self.rr_cumulant = rr_cumulant
71
+ self.rr_feats = tuple(rr_feats)
72
+ self.rr_bonus = float(rr_bonus)
73
+ self.rr_lib = rr_lib
74
+ # ★ 选择规则**所有臂共用同一个** —— 这样臂间的差异只能来自"子任务内容",
75
+ # 而不是来自某个选择启发式。规则本身是**另一条消融轴**。
76
+ # "uniform" 在可启动的 option 里均匀抽(每个库都有同等曝光)
77
+ # "gain" argmax(子任务价值 − V_main), 只在 > 0 时启动
78
+ # §3 的反面教训: 用 `V_main` 做增益基线时, `c=−1` 的瓶颈 option
79
+ # 价值为负 -> 永不启动 -> 负对照臂变成**空臂**(不是"更差")。
80
+ self.rr_select_rule = rr_select_rule
81
+ self.rr_max_duration = None if rr_max_duration is None else int(rr_max_duration)
82
+ self.stall_patience = int(stall_patience)
83
+
84
+ self.rr_lib_obj = None
85
+ self.rr_mdl = None
86
+ self._rr_regime = None
87
+ self._rr_cap = {} # option 名 -> 硬到期上限
88
+ # ── 计数器(§3: 模式的机制差异必须在**跑了之后**可被证伪) ──
89
+ self.rr_stats = {
90
+ "recomputes": 0, # π_o 重算次数(闭环执行 ≈ 执行步数)
91
+ "selections": 0, # 启动次数
92
+ "exec_steps": 0, # 在 option 内的总步数
93
+ "terms": {}, # 终止原因台账(**必须非空**)
94
+ "durations": [], # 每次执行的时长(检 lock-in)
95
+ "stall_baseline": [], # 停滞统计的基线样本
96
+ "rebuilds": 0,
97
+ }
98
+ # ══ 自我校准闭环(L2 缺的那一格)══════════════════════════════
99
+ # 这个 dict 是**按 option 归属**的台账。在此之前整个仓库唯一的
100
+ # 更新点是 skill_agent.py:240(旧 macro 路径)的全局计数器 ——
101
+ # `_rr_settle(self, st, steps, reason)` 收下 `st` 然后**完全忽略它**,
102
+ # 所以库级台账对 rr 路径不存在, "这个 option 从不达成目标" 这个
103
+ # 事实无处安放(L2 的 57 启动 / 56 expired / 1 reached 就是这样
104
+ # 被埋掉的: 没有任何东西因为该事实而改变)。
105
+ # 信号取 **option 自己的达成率**(β_at_feature), 不取主奖励 ——
106
+ # KeyDoor 的奖励是稀疏的(只在终点 +1), 拿主奖励做 option 级信号
107
+ # 会退化成"所有 option 都得 0"。
108
+ self.rr_outcome = {} # option 名 -> {"n", "reached", "steps"}
109
+ self.rr_min_launches = int(rr_min_launches) # 判"坏了"所需的最少启动数
110
+ self.rr_ucb_c = float(rr_ucb_c) # 校准选择的探索常数
111
+ self.rr_calib_min_rate = float(rr_calib_min_rate) # 达成率低于此 = 机制坏了
112
+ # ══ 持续性的两个旋钮(「持续自我校准」的"持续")══════════════════
113
+ # rr_persist 台账是否跨 regime 存活。
114
+ # False -> 每次库重建就清空(只在单 regime 内校准)
115
+ # True -> 账随 agent 走(校准成为**跨任务**的知识)
116
+ # rr_forget_hl **忘记半衰期**(以启动次数计)。inf = 不忘记 = 终身平均。
117
+ #
118
+ # ★ 为什么持久化必须配遗忘, 否则持久化是**有害**的:
119
+ # 无遗忘时 n 会跨 regime 累积到几百, 于是
120
+ # (a) 老的成功把新 regime 的失败稀释掉 —— 一个在第 2 个 regime
121
+ # 已经坏掉的 option, 终身达成率仍可能是 0.9, 检测不出来;
122
+ # (b) UCB 探索项 c·sqrt(ln N/(n+1)) 被大 n 压死 —— agent 不再
123
+ # 去重新检验它, 陈旧的判断被永久锁住。
124
+ # 「持续校准」必须跟踪的是**当前胜任度**, 不是历史平均。
125
+ # 实现: 有效计数按 decay = 0.5^(1/hl) 指数衰减。
126
+ # hl=inf -> decay=1.0 -> n_eff ≡ n, 逐位复现旧行为(向后兼容)。
127
+ self.rr_persist = bool(rr_persist)
128
+ self.rr_forget_hl = float(rr_forget_hl)
129
+ # rr_score 选择分数用什么信号
130
+ # "rate" 达成率(option 自己的成功) —— 机制内部视角
131
+ # "eff" 达成次数 / 消耗步数 —— **把主任务的成本接进来**
132
+ # 奖励稀疏时主奖励做不了 option 级信号, 但**时间是通用货币**:
133
+ # 视野有限, 白走的路严格是损失。所以"每步产出多少达成"是
134
+ # 一个不需要 oracle、又对主任务有意义的信号。
135
+ self.rr_score = rr_score
136
+ if rr_score not in ("rate", "eff", "adv", "advq"):
137
+ raise ValueError(f"unknown rr_score: {rr_score}")
138
+ # ══ 主任务信号(§8 明写的那个缺口, 现在被实测证据逼出来了)══════
139
+ # 为什么必须有: 只用 **option 自己的判据**(达成率)做校准是**错的**。
140
+ # 实测: 把一个 option 的上限压到 1 步 -> 它"达不成自己的子目标"
141
+ # (达成率 0.2), 但**它执行的那一步仍然是 π_o 的最优步**, 对主任务
142
+ # 有益。按内部判据把它淘汰 -> 白损失 π_o 的好步 -> 步数反而差
143
+ # 1.0 步(3 seed 一致)。**校准到错误的信号上, 会淘汰有用的东西。**
144
+ #
145
+ # 主任务信号取 **势函数差分 / 步数**:
146
+ # progress = Φ(终止状态) − Φ(起始状态), adv = Σprogress / Σsteps
147
+ # 这是 SMDP 视角下"这个 option 每消耗一步, 把主任务推进了多少"。
148
+ # Φ 取 **agent 自己的 max_a Q[s,a]** —— 在线、不需要 oracle。
149
+ # (早期 Q 是乐观常数 -> adv≈0 -> 判定"无信息", 这是诚实的行为;
150
+ # 它随学习变得有信息。`rr_pot="exact"` 用模型 VI 的 V_main 作对照,
151
+ # 用来分离"信号质量"与"机制结构"两个因素。)
152
+ self.rr_pot = rr_pot
153
+ if rr_pot not in ("learned", "exact", "gvf"):
154
+ raise ValueError(f"unknown rr_pot: {rr_pot}")
155
+ self.rr_calib_min_adv = float(rr_calib_min_adv)
156
+ # rr_retire_rule 用哪条判据决定淘汰
157
+ # "internal" 只看 option 自己的达成率 (旧行为)
158
+ # "adv" 只看主任务推进量
159
+ # "both" 两条都判坏才淘汰 (保守)
160
+ # "either" 任一条判坏就淘汰 (激进)
161
+ self.rr_retire_rule = rr_retire_rule
162
+ if rr_retire_rule not in ("none", "internal", "adv", "advq", "both",
163
+ "either", "all"):
164
+ raise ValueError(f"unknown rr_retire_rule: {rr_retire_rule}")
165
+ self.rr_regime_idx = -1 # 已见过的 regime 数(诊断用)
166
+ self.rr_calib_hist = [] # [{'regime': i, 'broken': [...], 'usage': {...}}]
167
+ # ══ option 的**学习价值**(SMDP)══════════════════════════════════
168
+ # ★ 这是对"在线可用势函数"问题的正确解 —— 不是找一个更好的 Φ,
169
+ # 而是**取消 Φ 这个中间量**,直接学 option 自己的价值。
170
+ #
171
+ # 为什么 Φ 这条路走不通:
172
+ # `adv = [Φ(s_end) − Φ(s_start)] / steps` 把两件事混在一起 ——
173
+ # (a) option 把我移动了
174
+ # (b) agent 在学习, 所以 Φ 自己变了
175
+ # 而且乐观初始化下 Φ(s_start) = Φ(s_end) = 常数 -> 恒等于 0。
176
+ #
177
+ # 正确的量是 **advantage**:
178
+ # A(s, o) = Q_opt(s, o) − V_base(s), V_base(s) = max_a Q[s, a]
179
+ # 它直接回答"从 s 执行这个 option, 比贪心走基元动作好多少" ——
180
+ # 这正是"要不要留它"该问的问题。阈值 0 在**同一奖励尺度**上有意义,
181
+ # 而且两边**从同一个初值出发**(都 q_init), 所以早期 A ≡ 0 =
182
+ # "还没有信息"(正确行为, 不是 bug)。
183
+ #
184
+ # SMDP 更新(每次 option 结算时):
185
+ # Q_opt[s0, o] += lr · [r_opt + γ^k · V(s1) − Q_opt[s0, o]]
186
+ # r_opt = option 执行期间累计的**主任务**奖励, k = 执行步数。
187
+ # 部分执行(到期/停滞/被中断)按截断处理, 仍是合法 bootstrap。
188
+ #
189
+ # ★ 判别器为什么**从数学里自己掉出来**(不是手设计的阈值):
190
+ # inert(上限压到1步, 但那1步是好的): Q_opt ≈ γ·V(s') 且 V(s') ≈ V(s)/γ
191
+ # => A ≈ 0 -> 不淘汰 ✓
192
+ # harm (反转 π_o, 朝反方向走): Q_opt ≈ γ·V(s_back), V(s_back) ≪ V(s)
193
+ # => A ≪ 0 -> 淘汰 ✓
194
+ self.q_opt = None # (n_states, n_opts) —— 惰性建表
195
+ self._rr_oidx = {} # option 名 -> 列号(跨 regime 稳定的身份)
196
+ self.rr_calib_min_advq = float(rr_calib_min_advq)
197
+ # ★ GVF 势函数: **中性初始化**(全 0)的状态价值表。
198
+ # 为什么不能借用 `self.q.Q` 的 max: 那张表是乐观初始化,
199
+ # 未试过的动作会把 max 顶在初值上 —— 实测 V(s0) 逐位 1.0000。
200
+ self.rr_V = np.zeros(self.mdp.n_states, dtype=float)
201
+ self.rr_lr_v = float(rr_lr_v)
202
+ # ★ "犯错是可以原谅的" = **退休可逆**。被判坏的 option 以 p 的概率被重新检验;
203
+ # 若其统计量恢复(它用的是同一批阈值判据), 下次 `rr_broken` 自然不再点名它。
204
+ self.rr_forgive_p = float(rr_forgive_p)
205
+ # ══ 三态 epistemic state(好 / 坏 / 未知)══════════════════════════════
206
+ # ★ 为什么必须有: 实测发现, `advq == 0` 在两种完全不同的情况下出现 ——
207
+ # (a) 证据充分且真的没有显著差异 -> 好
208
+ # (b) **奖励流为空, Φ 学不到东西**(harm 故障下整臂全灭时
209
+ # `Q_adv ≡ 0.0000` 精确零, 因为账压根没记) -> **未知**
210
+ # 把 (b) 读成"不坏", 就是**自欺**。系统的最大缺口不是精度, 是它
211
+ # 分不清这两种零。所以"未知"必须是一等状态, 不能被 0 顶上。
212
+ #
213
+ # "奖励流为空"的在线判据: 连续多少步没有拿到任何正奖励。
214
+ # 默认 3×horizon = "连着三个回合一分没拿" -> 势函数不可能在学东西。
215
+ self.rr_starve_steps = (int(rr_starve_steps) if rr_starve_steps
216
+ else 3 * int(getattr(mdp, "horizon", 30)))
217
+ self.rr_since_rew = 0 # 距上次拿到正奖励的步数
218
+ self.rr_starve_ever = 0 # 曾经进入"奖励流为空"的次数(诊断)
219
+ self.rr_unk_p = float(rr_unk_p) # "未知"状态的探索概率(有限度探索)
220
+ # ★ 三态开关。默认 **False** = 旧行为逐位不变(向后兼容 + 它本身是
221
+ # 一条消融轴: "把未知提升为一等状态, 到底改变了什么?")。
222
+ # True 时启用三件事: 缓存为空 -> 退回基元层; 未知 -> 有限度探索;
223
+ # 未知 -> 永不算坏。
224
+ self.rr_three_state = bool(rr_three_state)
225
+ # ── 阶段五: 证据库(evidence bank) ──────────────────────────────
226
+ # ★ 默认关闭 ⇒ 旧行为**逐位不变**(与 rr_three_state 同一条纪律)。
227
+ # ★ 语义: 证据**只降级(好->坏), 不升级** —— 与 A 类的反熟悉度约束同一条。
228
+ # 理由: 预测误差低只说明"我预测得准", 不说明"这件事有价值";
229
+ # 而**新颖度会拉高误差** ⇒ 允许升级就等于允许新颖度伪造"好"。
230
+ self.rr_evidence = bool(rr_evidence)
231
+ self.rr_ev_n_min = int(rr_ev_n_min)
232
+ self.rr_ev_z_bad = float(rr_ev_z_bad)
233
+ self.rr_ev_stale_hl = int(rr_ev_stale_hl)
234
+ self.rr_bank = None
235
+ self.rr_ev_step = 0
236
+ self.rr_ev_last = None # 最近一次的 support() 引用清单
237
+ self.rr_ev_bad_n = 0 # 证据库投了几次 bad(诊断用)
238
+ if self.rr_evidence:
239
+ from hibs_lnn.evidence_bank import EvidenceBank
240
+ self.rr_bank = EvidenceBank(
241
+ dim=len(self.mdp.vec()), seed=seed,
242
+ n_min=self.rr_ev_n_min, stale_hl=self.rr_ev_stale_hl,
243
+ z_bad=self.rr_ev_z_bad)
244
+ # ── A 类动力学证据(阶段四)─────────────────────────────────
245
+ # `rr_cumulant`(上面那个)是 **option 构造期**参数 —— 它是 OaK 的
246
+ # `(I_o, g_o, π_o, β_o)` 里的 `g_o`。A 类是**每步的动力学读数**,
247
+ # 是另一回事, 所以单开一个通道。
248
+ #
249
+ # ★ 方向约定: 三个 cumulant 都**统一成"压力"(越大越差)** ——
250
+ # A1 转移不确定性(归一化熵) / A2 转移预测误差(1−p(s2|s,a)) /
251
+ # A3 离开已知稳定动力学区域(1−熟悉度)。
252
+ # 这样它们**只能当风险证据**, 不可能被读成"值"。
253
+ self.rr_dyn = str(rr_dyn)
254
+ if self.rr_dyn not in ("none", "N", "A1", "A2", "A3"):
255
+ # "N" = 新颖度**控制量** —— 与 A1/A2/A3 走同一套管道,
256
+ # 用来回答"A 类到底是动力学探测器, 还是只是新颖度探测器"。
257
+ raise ValueError(f"unknown rr_dyn: {rr_dyn}")
258
+ self.rr_dyn_c = float(rr_dyn_c)
259
+ self._rr_T = {} # (s,a) -> {s2: n} 在线转移计数(只用真实转移)
260
+ self._rr_vis = {} # s -> n 访问次数
261
+ self._rr_dense = {} # option -> [n, Σ, Σ²] 动力学证据台账
262
+ self._rr_dseg = [0.0, 0.0, 0.0] # 全段基线 [n, Σ, Σ²] —— "显著更差"的参照
263
+ self._rr_dn = 0 # A 类喂了多少个转移
264
+ self._rr_prev_state = {} # option -> 上次判定(用来数退休/恢复次数)
265
+
266
+ # ── 技能库(每个 regime 重建一次 —— 目标随 regime 移动) ──────────
267
+ def _rr_ensure(self):
268
+ reg = tuple(self.mdp.regime)
269
+ if self._rr_regime == reg and self.rr_lib_obj is not None:
270
+ return
271
+ # ── regime 变了: 台账怎么处理 = 「持续」这条轴 ──────────────────
272
+ if self._rr_regime is not None: # 不是第一次
273
+ self.rr_regime_idx += 1
274
+ self.rr_calib_hist.append({
275
+ "regime": self.rr_regime_idx,
276
+ "broken": sorted(self.rr_broken()),
277
+ "usage": self._rr_usage(),
278
+ })
279
+ if not self.rr_persist:
280
+ # 只在本 regime 内校准 —— 每换一次世界, 经验清零
281
+ self.rr_outcome = {}
282
+ mdl = KeyDoorTabular(self.mdp, gamma=self.q.gamma)
283
+ self.rr_mdl = mdl
284
+ if self.rr_v_main == "exact":
285
+ V_main = main_value(mdl)
286
+ elif self.rr_v_main == "learned":
287
+ # agent 自己的 Q 表 —— 在线版, 不需要 oracle
288
+ V_main = np.max(self.q.Q, axis=1).copy()
289
+ else:
290
+ raise ValueError(f"unknown rr_v_main: {self.rr_v_main}")
291
+ self._rr_v_main_arr = V_main
292
+
293
+ if self.rr_lib == "rr":
294
+ lib = build_rr_library(mdl, V_main, bonus_weight=self.rr_bonus,
295
+ feats=self.rr_feats,
296
+ cumulant=self.rr_cumulant,
297
+ z_mode=self.rr_z_mode)
298
+ elif self.rr_lib == "bottleneck":
299
+ lib = build_bottleneck_library(mdl)
300
+ elif self.rr_lib == "random":
301
+ lib = build_random_goal_library(mdl, V_main, seed=self.rng.randint(1 << 30))
302
+ else:
303
+ raise ValueError(f"unknown rr_lib: {self.rr_lib}")
304
+
305
+ self.rr_lib_obj = lib
306
+ self._rr_regime = reg
307
+ self.rr_stats["rebuilds"] += 1
308
+ # 每个 option 的硬到期上限: **由它自己的名义时长导出**。
309
+ # 名义时长 = 从起始状态出发、按 π_o 走、直到终止判据命中所需步数
310
+ # (模型确定性, 直接模拟)。
311
+ self._rr_cap = {}
312
+ for st in lib:
313
+ nom = self._rr_nominal(st)
314
+ cap = int(self.rr_max_duration) if self.rr_max_duration else max(3, 2 * nom)
315
+ self._rr_cap[st.name] = cap
316
+
317
+ def _rr_nominal(self, st):
318
+ """模拟 π_o 从**回合起始状态 0** 出发到自己终止要多少步。
319
+
320
+ 模型确定性, 直接前向模拟。这个数��� `expired` 上限的依据 ——
321
+ §6 要求上限由 option 自身的名义时长导出, 不是拍脑袋常数。
322
+ """
323
+ s = 0
324
+ for k in range(1, self.mdp.horizon + 1):
325
+ if st.policy[s] == STOP:
326
+ return k
327
+ nxt = self.rr_mdl.T[s][st.policy[s]][0][1]
328
+ if self.rr_mdl.is_goal[nxt] or float(getattr(st, "feature")[nxt]) > 0:
329
+ return k
330
+ if st.beta[nxt]:
331
+ return k
332
+ if nxt == s:
333
+ return k
334
+ s = nxt
335
+ return self.mdp.horizon
336
+
337
+ # ── 选择 ────────────────────────────────────────────────────────
338
+ def _rr_select(self, s):
339
+ """选一个 option。规则由 `rr_select_rule` 决定(所有臂共用)。
340
+
341
+ "uniform" —— 在**可启动**(I_o 成立)的 option 里均匀抽。
342
+ 每个库都有同等曝光, 所以臂间差异只能来自子任务内容。
343
+ "gain" —— `argmax`(子任务价值 − V_main), 且增益必须 **> 0**。
344
+ 这是"哪个子任务现在最值得追"的贪心读法, 本身是一条
345
+ 消融轴(更强的先验 = 更多 oracle 信息)。
346
+ "calibrated" —— **自我校准**: 只用**自己的实测达成率**排序,
347
+ 不用任何假设的价值。UCB1 形式:
348
+ score = reached/n + c·sqrt(ln(N+1)/(n+1))
349
+ 并且**排除被判"坏了"的 option**(见 `rr_broken`)。
350
+ 这是"犯错 -> 发现 -> 改正"的闭环本身, 与其它两条
351
+ 规则的关键区别是: 它**不引入任何外部先验**,
352
+ "哪个 option 好"完全由 agent 自己的经验决定。
353
+ """
354
+ cand = [st for st in self.rr_lib_obj if initiation_ok(st, s)]
355
+ # 选择**尝试**总数 —— fallback 占比的分母必须是它, 不能是
356
+ # `selections`(那只在真的选中时加一, 比值会 > 1, 实测 6.827)。
357
+ self.rr_stats["sel_attempts"] = self.rr_stats.get("sel_attempts", 0) + 1
358
+ if not cand:
359
+ return None
360
+ rule = self.rr_select_rule
361
+ if rule == "gain":
362
+ best, best_g = None, 0.0
363
+ for st in cand:
364
+ g = option_gain(st, s, self._rr_v_main_arr)
365
+ if g > best_g:
366
+ best, best_g = st, g
367
+ return best
368
+ if rule == "calibrated":
369
+ # ★ 奖励流为空 -> **退回安全基元策略**。这不是放弃, 是承认"现在
370
+ # 什么都判不了": Φ 学不到东西, 任何判断都是自欺。等主任务拿到
371
+ # 奖励、Φ 重新有内容, 再回来判。
372
+ if self.rr_three_state and self.rr_starved():
373
+ self.rr_stats["starved_sel"] = self.rr_stats.get("starved_sel", 0) + 1
374
+ return None
375
+ broken = self.rr_broken()
376
+ live = [st for st in cand if getattr(st, "name", None) not in broken]
377
+ # ★★ **原谅**(`rr_forgive_p`)—— "犯错是可以原谅的"的机制含义:
378
+ # 被判坏的 option **不是被处决**, 而是以 p 的概率被**重新检验**。
379
+ #
380
+ # 没有这条路就有一个**死锁**: 早期 V 还没学到东西(bootstrap 暂态),
381
+ # option 的价值天然偏低 -> 判坏 -> 不再使用 -> 统计量永不更新 ->
382
+ # 永远坏。实测就是这么崩的: advq 恒 -0.004, fallback 0.999。
383
+ # **退休必须可逆, 否则一次误判 = 永久损失。**
384
+ dea = [st for st in cand if getattr(st, "name", None) in broken]
385
+ if dea and self.rr_forgive_p > 0 and self.rng.rand() < self.rr_forgive_p:
386
+ self.rr_stats["forgiven"] = self.rr_stats.get("forgiven", 0) + 1
387
+ return dea[int(self.rng.randint(len(dea)))]
388
+ if not live: # 全坏了 -> 退回基元层(诚实行为)
389
+ return None
390
+ # ★ 三态驱动的选择(leo 契约: "未知应触发有限度探索"):
391
+ # 未知 -> 以 `rr_unk_p` 的概率**优先挑它** —— 不确定的 option
392
+ # 得先攒到证据, 才可能从"未知"变成"好/坏"。没有这条,
393
+ # 它永远停在未知(不被选 -> 没证据 -> 不被选)。
394
+ # 好 -> 正常 UCB 参与选择。
395
+ # 坏 -> 只在原谅概率下被重新检验。
396
+ unk = [st for st in live
397
+ if self.rr_epistemic(self.rr_outcome.get(
398
+ getattr(st, "name", None), {})) == "unknown"]
399
+ if unk and self.rr_three_state and self.rng.rand() < self.rr_unk_p:
400
+ self.rr_stats["unk_probe"] = self.rr_stats.get("unk_probe", 0) + 1
401
+ return unk[int(self.rng.randint(len(unk)))]
402
+ N = sum(o.get("n_eff", 0.0) for o in self.rr_outcome.values())
403
+ best, best_sc = None, -np.inf # adv 分数可为负 -> 不能用 -1.0 做初值
404
+ for st in live:
405
+ o = self.rr_outcome.get(getattr(st, "name", None))
406
+ if o is None: # 没试过的 option: 无限乐观, 先试它
407
+ return st
408
+ n = o.get("n_eff", 0.0)
409
+ sc = self._rr_score_of(o) + self.rr_ucb_c * float(
410
+ np.sqrt(np.log(N + 1.0) / (n + 1.0)))
411
+ if sc > best_sc:
412
+ best, best_sc = st, sc
413
+ return best
414
+ return cand[int(self.rng.randint(len(cand)))]
415
+
416
+ # ── 一个回合(rr 路径; 其他模式逐字委托父类) ──────────────────
417
+ def run_episode(self):
418
+ if self.mode != "rr":
419
+ return super().run_episode() # 对照臂路径**逐字不变**
420
+ self._rr_ensure()
421
+ s = self.mdp.reset()
422
+ done, n = False, 0
423
+ active, o_steps, prev_s, stuck, o_start = None, 0, None, 0, None
424
+ o_rew = 0.0 # option 执行期间的**主任务**累计奖励(SMDP 更新用)
425
+ while not done and n < self.mdp.horizon:
426
+ v0 = self.mdp.vec() # ★ **步前**状态 —— 与父类 run_episode 同约定。
427
+ a = None
428
+ if self.rng.rand() < self.opt_prob or active is not None:
429
+ # 启动
430
+ if active is None:
431
+ active = self._rr_select(s)
432
+ o_steps, prev_s, stuck, o_start, o_rew = 0, s, 0, s, 0.0
433
+ if active is not None:
434
+ self.rr_stats["selections"] += 1
435
+ # 出动作: **每步按当前状态重算 π_o**(闭环)
436
+ if active is not None:
437
+ if int(active.policy[s]) != STOP:
438
+ a = int(active.policy[s])
439
+ self.rr_stats["recomputes"] += 1
440
+ else:
441
+ self._rr_settle(active, o_steps, "beta_stop", o_start, s,
442
+ r_opt=o_rew)
443
+ active, o_steps, o_start = None, 0, None
444
+ if a is None:
445
+ if active is not None:
446
+ self._rr_settle(active, o_steps, "devalued", o_start, s,
447
+ r_opt=o_rew)
448
+ active, o_steps, o_start = None, 0, None
449
+ a = self.q.act(s)
450
+ s2, r, done = self.mdp.step(a)
451
+ self.q.update(s, a, r, s2, done)
452
+ # ★ GVF 势函数: **每个转移都喂**, 不论那一步是 option 出的还是基元出的。
453
+ # 这就是 option 绕过 Q 也不影响它的原因 —— 它学的是"走到哪值多少",
454
+ # 不是"哪个动作好"。
455
+ self._rr_vstep(s, r, s2, done)
456
+ # ★ A 类动力学证据: **先读数(用更新前的表)再更新** —— 这是
457
+ # "先预测、再观测"的正确在线语义。**失败时这行照样执行**:
458
+ # 到不了终点也有真实转移, 这正是 A 类作为候选的全部理由。
459
+ if self.rr_dyn != "none":
460
+ _dv = self._rr_dval(s, a, s2)
461
+ self._rr_dstep(s, a, s2)
462
+ self._rr_dacc(active.name if active is not None
463
+ else "__primitive__", _dv)
464
+ n += 1
465
+ # ★ 奖励流监控: "连续多少步一分没拿"。Φ 从奖励流学, 奖励流为空
466
+ # 时 Φ 恒为初值 —— 此时任何"不坏"的结论都是自欺。
467
+ self.rr_since_rew = 0 if float(r) > 0 else (self.rr_since_rew + 1)
468
+ # ── 阶段五: 喂证据库 ────────────────────────────────────────
469
+ # ★ `phi=v0, phi_next=self.mdp.vec()` —— **不能两个都传 `self.mdp.vec()`**。
470
+ # `self.mdp` 在 `step()` 里已经前进, 两个都传当前 vec 会让 `v_next == v`,
471
+ # TD 的 `gamma*v_next` 项与 `v` 抵消 ⇒ 学的是"瞬时误差"不是值。
472
+ # (`hibs_lnn/oak_proposer.py:338` 的 `observe_gvf(phi, cums, phi)` 正是
473
+ # 这个形态; 本模块刻意避开, 见 docs/stage5_evidence_bank_spec.md §0。)
474
+ # 四个 cumulant = 这一步**四个不同的可观测事实**:
475
+ # prediction → "有 option 正在执行" reward → 即时奖励
476
+ # transition → "状态没变(自环)" regime → "本回合结束"
477
+ if self.rr_bank is not None:
478
+ _cums = (1.0 if active is not None else 0.0,
479
+ float(r),
480
+ 1.0 if s2 == s else 0.0,
481
+ 1.0 if done else 0.0)
482
+ self.rr_ev_step += 1
483
+ self.rr_bank.observe(v0, _cums, self.mdp.vec(),
484
+ step=self.rr_ev_step,
485
+ regime=self.rr_regime_idx)
486
+ self.trans.append((v0, a, self.mdp.vec())) # (步前, 动作, 步后)
487
+ # ── 结算判据: 全部在**新状态**上评估(正确语义) ──────────
488
+ if active is not None:
489
+ o_steps += 1
490
+ o_rew += float(r) # 主任务奖励记到**当前这个 option** 头上
491
+ self.rr_stats["exec_steps"] += 1
492
+ reason = None
493
+ # ★ 主判据 = **option 自己的 β_o**(由 stopping value z 导出)。
494
+ # 这是机制本体 —— 之前把"特征达成"排在 β 之前, 导致所有
495
+ # 变体在特征亮起的同一瞬间就停, z 永远轮不到起作用,
496
+ # 5 个 rr 变体的台账**逐位相同**(Pitfall-26 tell)。
497
+ # "恰好停在特征上"现在作为 β_o 的一个**子理由**计数,
498
+ # 这样既能证明机制在跑, 又能看出它停在哪。
499
+ if bool(active.beta[s2]):
500
+ reason = ("beta_at_feature" if float(active.feature[s2]) > 0
501
+ else "beta_elsewhere")
502
+ elif done:
503
+ reason = "horizon_end"
504
+ elif o_steps >= self._rr_cap.get(active.name, self.mdp.horizon):
505
+ reason = "expired" # ③ 硬到期
506
+ elif s2 == prev_s:
507
+ stuck += 1
508
+ self.rr_stats["stall_baseline"].append(stuck)
509
+ if stuck >= self.stall_patience:
510
+ reason = "stall" # ④ 撞墙空转
511
+ else:
512
+ stuck = 0
513
+ if reason:
514
+ self._rr_settle(active, o_steps, reason, o_start, s2,
515
+ r_opt=o_rew, done=bool(done))
516
+ active, o_steps, o_start = None, 0, None
517
+ prev_s = s2
518
+ s = s2
519
+ if active is not None: # 回合结束仍在执行
520
+ self._rr_settle(active, o_steps, "horizon_end", o_start, s,
521
+ r_opt=o_rew, done=True)
522
+ self.episodes += 1
523
+ self._rr_sync_states() # 三态判定 + 退休/恢复计数(每回合一次)
524
+ return bool(done and self.mdp._reached()), n
525
+
526
+ # ── 有效计数(遗忘)──────────────────────────────────────────────
527
+ def _rr_decay(self):
528
+ """每次启动后, 旧统计量保留的比例。`hl=inf` -> 1.0(不遗忘)。"""
529
+ hl = self.rr_forget_hl
530
+ if hl is None or not np.isfinite(hl) or hl <= 0:
531
+ return 1.0
532
+ return float(0.5 ** (1.0 / hl))
533
+
534
+ @staticmethod
535
+ def _rr_rate(o):
536
+ """当前胜任度 = reached_eff / n_eff。不遗忘时逐位等于终身平均。"""
537
+ ne = o.get("n_eff", float(o.get("n", 0)))
538
+ return (o.get("reached_eff", float(o.get("reached", 0))) / ne) if ne > 0 else 0.0
539
+
540
+ @staticmethod
541
+ def _rr_eff(o):
542
+ """**效率** = 达成次数 / 消耗步数(每步产出多少达成)。
543
+
544
+ 这是不依赖 oracle 的"主任务成本"信号: 视野有限, 白走的路严格是损失。
545
+ 与 `rate` 的区别: 一个"总能达成但每次都绕远路"的 option,
546
+ `rate` 高而 `eff` 低 —— 后者才是对主任务真正有意义的量。
547
+ """
548
+ return o.get("reached_eff", 0.0) / max(1.0, o.get("steps_eff", 1.0))
549
+
550
+ def _rr_score_of(self, o):
551
+ s = self.rr_score
552
+ if s == "rate":
553
+ return self._rr_rate(o)
554
+ if s == "eff":
555
+ return self._rr_eff(o)
556
+ if s == "advq":
557
+ return self._rr_advq_of(o)
558
+ return self._rr_adv(o)
559
+
560
+ @staticmethod
561
+ def _rr_adv(o):
562
+ """主任务推进量 = Σ势函数差分 / Σ步数(每步把主任务推进多少)。"""
563
+ return o.get("adv_sum", 0.0) / max(1.0, o.get("adv_steps", 1.0))
564
+
565
+ def _rr_pot(self, s):
566
+ """势函数 Φ(s)。**必须是一个独立学习、中性初始化的状态价值预测**(GVF)。
567
+
568
+ 三个来源, 依次说明为什么只有第三个在线可用:
569
+
570
+ `learned` —— `max_a Q[s,a]`, agent 的动作价值表取最大。
571
+ 实测**不可用**: 该表是**乐观初始化**(q_init=+1.0), 只要某个动作
572
+ 还没试过, `max_a` 就还是 1.0。实测 option 的启动状态上
573
+ `V(s0)` **逐位等于 1.0000**(初值), 而 `γ^k·V(s1) = 0.717` ->
574
+ advantage ≡ −0.275 —— **量出来的是初值 artifact, 不是 option 好坏**。
575
+ (32 个状态里只有 14 个有任一 Q 离开初值, 但"最大值"几乎都还在初值上。)
576
+
577
+ `exact` —— 模型 VI 的 `V_main`。干净, 但**是 oracle**(用了真转移模型),
578
+ 只能当"信号质量"的对照, 不能当在线方案。
579
+
580
+ `gvf` —— ★ **对"在线可用势函数"的答案**: 一张中性初始化(全 0)的
581
+ 状态价值表, 用 TD(0) 从 agent **自己走的每一步**学, 不管那一步是
582
+ 基元动作还是 option 内部的动作:
583
+
584
+ V[s] += lr_v · [r + γ·V[s'] − V[s]]
585
+
586
+ 为什么这样就成了:
587
+ · **中性初值** -> 没有"未试过的动作把 max 顶在初值"这个病;
588
+ · 奖励稀疏(+1 只在终点) -> `V(s)` 自然收敛成 γ^(到目标的步数),
589
+ 这**正是**我们要的"离目标多远"的势函数;
590
+ · **每个状态都被喂** -> option 绕过 Q 也没关系, 因为喂 V 的是
591
+ 每一步的真实转移, 不是动作选择;
592
+ · 不用转移模型、不用目标位置的坐标 -> **无 oracle**。
593
+ """
594
+ if s is None:
595
+ return 0.0
596
+ if self.rr_pot == "exact":
597
+ return float(self._rr_v_main_arr[s])
598
+ if self.rr_pot == "gvf":
599
+ return float(self.rr_V[s])
600
+ return float(np.max(self.q.Q[s]))
601
+
602
+ def _rr_vstep(self, s, r, s2, done):
603
+ """GVF 势函数的一步 TD —— 每个转移都喂, 无论那一步是谁出的动作。"""
604
+ tgt = float(r) if done else float(r) + float(self.q.gamma) * self.rr_V[s2]
605
+ self.rr_V[s] += self.rr_lr_v * (tgt - self.rr_V[s])
606
+
607
+ def _rr_has_base(self, s):
608
+ """**势函数在该状态上是否有证据**(离开中性初值)。
609
+
610
+ 为什么必须有这个门: 在没有任何经验的区域上比较"option 比基线好不好",
611
+ 比出来的是**初值的形状**, 不是经验。这不是调阈值, 是判断的前提条件 ——
612
+ "要判断这里用 option 划不划算, 前提是在这里有过经验"。
613
+ """
614
+ if s is None:
615
+ return False
616
+ if self.rr_pot == "gvf":
617
+ return bool(abs(float(self.rr_V[s])) > 1e-12)
618
+ return bool(np.any(self.q.Q[s] != self.q.q_init))
619
+
620
+ # ── option 的学习价值(SMDP)──────────────────────────────────────
621
+ def _rr_oindex(self, name):
622
+ """option 名 -> `q_opt` 的列号。名跨 regime 稳定, 所以身份稳定。"""
623
+ if name not in self._rr_oidx:
624
+ self._rr_oidx[name] = len(self._rr_oidx)
625
+ n_col = len(self._rr_oidx)
626
+ if self.q_opt is None:
627
+ # 与基元 Q **同一初值** —— 这样早期 advantage ≡ 0 = "还没有信息",
628
+ # 而不是"所有 option 都很差"。
629
+ self.q_opt = np.full((self.mdp.n_states, max(n_col, 1)),
630
+ float(self.q.q_init))
631
+ elif self.q_opt.shape[1] < n_col:
632
+ pad = np.full((self.mdp.n_states, n_col - self.q_opt.shape[1]),
633
+ float(self.q.q_init))
634
+ self.q_opt = np.hstack([self.q_opt, pad])
635
+ return self._rr_oidx[name]
636
+
637
+ def _rr_vbase(self, s):
638
+ """V_base(s) = max_a Q[s, a] —— **只用基元动作**的价值。
639
+
640
+ 这是 advantage 的比较基线: "不用这个 option, 我靠基元动作能拿到多少"。
641
+ 它必须是**基元专属**的, 不能把 option 的价值混进来, 否则
642
+ `Q_opt − V` 恒 <= 0(自己不可能超过包含自己的最大值)。
643
+ """
644
+ return float(np.max(self.q.Q[s]))
645
+
646
+ def _rr_vall(self, s):
647
+ """V_all(s) = max(基元, option) —— agent **实际行为**(含 option)的价值。
648
+
649
+ ★ SMDP 的 bootstrap 必须用它, 不能用 `_rr_vbase`: option 之间是可以
650
+ 串联的(先拿钥匙再开门), 如果 option 从"基元专属"价值 bootstrap,
651
+ option 链上的价值就传不下去 —— 每次传播只能走一层。
652
+ """
653
+ v = float(np.max(self.q.Q[s]))
654
+ if self.q_opt is not None and self.q_opt.shape[1]:
655
+ v = max(v, float(np.max(self.q_opt[s])))
656
+ return v
657
+
658
+ @staticmethod
659
+ def _rr_advq_of(o):
660
+ """advantage = Q_opt − Φ(s0) 的(遗忘加权)均值。正 = 比基线好。"""
661
+ return o.get("advq_sum", 0.0) / max(1.0, o.get("advq_n", 1.0))
662
+
663
+ @staticmethod
664
+ def _rr_advq_se(o):
665
+ """上面那个均值的**标准误**(遗忘加权)。没有它就不能说"显著更差"。"""
666
+ n = max(1.0, o.get("advq_n", 0.0))
667
+ m = o.get("advq_sum", 0.0) / n
668
+ v = max(0.0, o.get("advq_sq", 0.0) / n - m * m)
669
+ return float(np.sqrt(v / n))
670
+
671
+ # ── 三态 epistemic state ────────────────────────────────────────
672
+ # ── A 类动力学 cumulant(阶段四)────────────────────────────────
673
+ def _rr_dval(self, s, a, s2):
674
+ """A 类 cumulant 的**行前读数**(用更新前的表 —— 先预测再观测)。
675
+
676
+ 三条共同契约(leo 的四条要求里最硬的两条):
677
+ · 每一步都能在线获得 —— 只看刚发生的真实转移;
678
+ · **不用目标坐标、不用真实模型、不用未来标签** —— 只有
679
+ `(s, a, s2)` 三元组的计数, agent 自己走出来的;
680
+ · 统一方向 = "压力", 越大越差, ⇒ 天然不可能被读成"值"。
681
+ """
682
+ if self.rr_dyn == "none":
683
+ return 0.0
684
+ d = self._rr_T.get((int(s), int(a)))
685
+ n = sum(d.values()) if d else 0
686
+ if self.rr_dyn == "N":
687
+ # ★ **控制量: 新颖度**。`1/(1+n(s,a))`, 用**更新前**的计数。
688
+ # 它必须与 A1/A2/A3 **走同一套管道**(同一个 _rr_dval/_rr_dacc/
689
+ # 基线与判据), 否则"A 与 N 有没有分开"比的是实现差异而不是语义。
690
+ # 方向同样统一成"压力": 越新 -> 越大。
691
+ return float(1.0 / (1.0 + n))
692
+ if self.rr_dyn == "A1":
693
+ # ★ A1 = **转移预测误差**(按 leo 的 A′ 规格)。
694
+ # 语义: 用**更新前**的模型预测这一步会落在哪, 再看实际落在哪。
695
+ # ⇒ "意外度" = 1 − p̂_prev(实际后继 | s,a)。
696
+ # ★ 这是**可预测性**, 不是任务价值 —— 一个稳定把你带向
697
+ # 错误方向的动作照样很容易预测(leo 点名的那条)。
698
+ # ★ 与 A2 的分工: A1 对**分布翻转**敏感(A2 不敏感),
699
+ # A2 对**支撑集扩张**敏感(A1 也敏感) —— 两者在翻转环境上
700
+ # **必然分歧**, 这正是检验"它们是不是同一个量"的机会。
701
+ #
702
+ # 注意: 本分支与 A2 早先**写反了**(旧 A1 = 熵, 旧 A2 = 1−p̂)。
703
+ # 在 A′ 里按 leo 规格对齐: A1 = 预测误差, A2 = 不确定性。
704
+ if n < 2:
705
+ return 1.0 # 无先验 ⇒ 没有"预测"可供做错, 报未知
706
+ return float(1.0 - d.get(int(s2), 0) / n)
707
+ if self.rr_dyn == "A2":
708
+ # ★ A2 = **转移不确定性** = 观测到的后继分布归一化熵。
709
+ # ★ 关键区别: 熵对 **{0.9,0.1} 与 {0.1,0.9} 给出同一个数**,
710
+ # 所以分布翻转时 A2 **不跳** —— 这是 A1 ≠ A2 的结构性证据。
711
+ # ★ 但 A2 对"支撑集扩张"(0.9/0.1 -> 0.5/0.5) 敏感。
712
+ if n < 2:
713
+ return 1.0 # 见过的样本不足 ⇒ 报最大不确定
714
+ p = np.array(list(d.values()), float)
715
+ p /= p.sum()
716
+ return float(-(p * np.log(p + 1e-12)).sum()
717
+ / np.log(max(2, getattr(self.mdp, "n_states", 32))))
718
+ # A3 离开已知稳定动力学区域: 落点的**出发边平均熵**(归一化)。
719
+ # ★ 方向必须与 A1/A2 一致 = "压力"(越大越差)。第一版写成了
720
+ # `1 - 熵`, 返回的是**熟悉度** —— 与通道约定相反, 于是读数恒
721
+ # 1.000(确定性 MDP 里熵恒 0)、永不触发。这是"通道方向写反"
722
+ # 的典型样子: 指标看起来在动, 但基线与判据读的是相反的量。
723
+ ns = int(s2)
724
+ if self._rr_vis.get(ns, 0) == 0:
725
+ return 1.0 # 全新状态 -> 最大压力
726
+ ents = []
727
+ # 用**观测到的**出边动作, 不硬编码 (0,1) —— 第一版漏掉了 GRAB/OPEN,
728
+ # 而 KeyDoor 的动作是 LEFT/RIGHT/GRAB/OPEN 四个。
729
+ acts = {a2 for (ns2, a2) in self._rr_T if ns2 == ns}
730
+ for a2 in (sorted(acts) if acts else (0, 1)):
731
+ dd = self._rr_T.get((ns, int(a2)))
732
+ nn = sum(dd.values()) if dd else 0
733
+ if nn >= 2:
734
+ pp = np.array(list(dd.values()), float)
735
+ pp /= pp.sum()
736
+ ents.append(float(-(pp * np.log(pp + 1e-12)).sum()))
737
+ if not ents:
738
+ return 1.0 # 出边一条都没摸过
739
+ return float(min(1.0, np.mean(ents)
740
+ / np.log(max(2, getattr(self.mdp, "n_states", 32)))))
741
+
742
+ def _rr_dstep(self, s, a, s2):
743
+ """把这一步的真实转移记进在线转移表 —— **失败时这行照样执行**。"""
744
+ k = (int(s), int(a))
745
+ d = self._rr_T.get(k)
746
+ if d is None:
747
+ d = self._rr_T[k] = {}
748
+ d[int(s2)] = d.get(int(s2), 0) + 1
749
+ self._rr_vis[int(s2)] = self._rr_vis.get(int(s2), 0) + 1
750
+ self._rr_dn += 1
751
+
752
+ def _rr_dacc(self, name, d):
753
+ """把读数记到 **具体 option** 的台账 + 全段基线。"""
754
+ L = self._rr_dense.get(name)
755
+ if L is None:
756
+ L = self._rr_dense[name] = [0.0, 0.0, 0.0]
757
+ L[0] += 1.0
758
+ L[1] += d
759
+ L[2] += d * d
760
+ B = self._rr_dseg
761
+ B[0] += 1.0
762
+ B[1] += d
763
+ B[2] += d * d
764
+
765
+ def _rr_dyn_bad(self, name):
766
+ """A 类风险判据: 该 option 的动力学压力是否**显著高于本段基线**。
767
+
768
+ 尺度**���全由数据自己的方差给出**(两样本 SE), 不引入手工 ε ——
769
+ 与 leo 在 inert 上要求的"'接近零'必须使用健康段自身的方差定义"同一条。
770
+ """
771
+ if self.rr_dyn == "none":
772
+ return False
773
+ L = self._rr_dense.get(name)
774
+ if L is None or L[0] < self.rr_min_launches:
775
+ return False
776
+ Bn, Bs, Bs2 = self._rr_dseg
777
+ if Bn < 20 or Bn <= L[0]:
778
+ return False
779
+ m = L[1] / L[0]
780
+ v = max(0.0, L[2] / L[0] - m * m)
781
+ bm = Bs / Bn
782
+ bv = max(0.0, Bs2 / Bn - bm * bm)
783
+ se = math.sqrt(v / L[0] + bv / Bn)
784
+ return (m - bm) > self.rr_dyn_c * se
785
+
786
+ def rr_dyn_stats(self):
787
+ """A 类通道的报告: 证据量 / 均值 / 是否触发风险。"""
788
+ Bn, Bs, Bs2 = self._rr_dseg
789
+ bm = Bs / Bn if Bn else 0.0
790
+ per = {}
791
+ for k in sorted(self._rr_dense):
792
+ L = self._rr_dense[k]
793
+ m = L[1] / L[0] if L[0] else 0.0
794
+ per[k] = {"n": int(L[0]), "mean": round(m, 4),
795
+ "risk": bool(self._rr_dyn_bad(k))}
796
+ return {"kind": self.rr_dyn, "transitions": int(self._rr_dn),
797
+ "base_mean": round(bm, 4), "base_n": int(Bn), "per_option": per}
798
+
799
+ def rr_starved(self):
800
+ """奖励流是否为空 —— 势函数不可能在学东西。
801
+
802
+ ★ 这是本轮最重要的那个门。实测: harm 故障下整臂全灭时
803
+ `Q_adv ≡ 0.0000` **精确为零** —— 不是"没有显著差异", 是"根本没记账"
804
+ (到不了终点 -> 无奖励 -> Φ 恒为 0 -> 证据门 `_rr_has_base` 挡掉一切)。
805
+ 不加这个门, 系统会把"我不知道"读成"没问题" —— **自欺**。
806
+ """
807
+ return self.rr_since_rew >= self.rr_starve_steps
808
+
809
+ def rr_epistemic(self, o):
810
+ """**三态判定**: `"good"` / `"bad"` / `"unknown"`。
811
+
812
+ 与旧 `rr_broken` 的关键区别: **"未知"不是"好"**。
813
+ 旧代码 `advq = 0 -> 不 < 0 -> 不淘汰 -> 当作健康`, 而在奖励流为空时
814
+ `advq` 恰好是精确的 0 —— 于是"没有证据"被系统性地读成"没问题"。
815
+
816
+ 判据(与 leo 定的契约一致):
817
+ 未知 —— 奖励流为空 / 势函数未离开初值 / 证据量不足(< min_launches)
818
+ 坏 —— 证据充分, 且**上置信界 < 0**(显著更差)
819
+ 好 —— 证据充分, 且没有显著负向信号
820
+ """
821
+ if self.rr_starved():
822
+ return "unknown" # Φ 学不到 -> 无有效预测证据
823
+ n = o.get("advq_n", 0.0)
824
+ if n < self.rr_min_launches:
825
+ return "unknown" # 证据不足
826
+ m = self._rr_advq_of(o)
827
+ if m + self.rr_ucb_c * self._rr_advq_se(o) < 0.0:
828
+ return "bad"
829
+ # ★★ **反熟悉度保证**: A 类动力学证据**只能降级(好 -> 坏)**,
830
+ # **永远不能升级(未知 -> 好)**。也就是说"可预测"买不到"好" ——
831
+ # 否则一个稳定把你带向错误方向的 option 会因为好预测而被判好。
832
+ # 这条不是调出来的, 是设计约束: A 类是**风险通道**, 不是值信号。
833
+ # (它到底能不能当值信号, 由"稳定但错误"否证实验来判。)
834
+ nm = o.get("name")
835
+ if nm is not None and self._rr_dyn_bad(nm):
836
+ return "bad"
837
+ # ── 阶段五: 证据库(★ **只降级, 不升级**)──────────────────────
838
+ # 与 A 类同一条不对称约束: 证据可以投 `bad`, 但**永远不能**把状态升成
839
+ # `good`。理由: 预测误差低只说明"我预测得准", 不说明"这件事有价值";
840
+ # 而**新颖度会拉高误差** ⇒ 允许升级就等于允许新颖度伪造"好"。
841
+ # ⇒ 证据缺席不改变判定, 证据示警才降级。
842
+ if self.rr_bank is not None:
843
+ self.rr_ev_last = self.rr_bank.support(step=self.rr_ev_step,
844
+ regime=self.rr_regime_idx)
845
+ if self.rr_ev_last.get("verdict") == "bad":
846
+ self.rr_ev_bad_n += 1
847
+ return "bad"
848
+ return "good"
849
+
850
+ def rr_ev_support(self):
851
+ """★ 最近一次证据判定背后的**引用清单** —— 哪条支持 bad / 哪条缺席。
852
+
853
+ 这是"证据可追溯"的直接接口: 调用方拿到的不是分数, 是**来源**。
854
+ """
855
+ if self.rr_bank is None:
856
+ return {"enabled": False}
857
+ s = self.rr_bank.support(step=self.rr_ev_step, regime=self.rr_regime_idx)
858
+ s["enabled"] = True
859
+ s["n_bad_ever"] = int(self.rr_ev_bad_n)
860
+ return s
861
+
862
+ def rr_complementarity(self, label="reward"):
863
+ """★ 这组 GVF 到底有没有产生**互补证据**。
864
+
865
+ `label`: 用哪个 cumulant 的二值化当"变化/未变化"标签。
866
+ 默认 `"reward"` ⇒ 问的是「这组证据在**奖励到达**这件事上互不互补」。
867
+
868
+ ⚠ 口径限制(必须写明): 标签本身就是四个 cumulant 之一,
869
+ 所以对 `reward` 那条 GVF 天然有利。读 `complementarity` 时要同时看
870
+ `auc_i`(**单条**的 AUC)与 `corr_err`(冗余度), 不能只看差值。
871
+
872
+ **允许结论是"冗余"** —— 那就照实报冗余。
873
+ """
874
+ if self.rr_bank is None:
875
+ return {"enabled": False}
876
+ from hibs_lnn.evidence_bank import complementarity
877
+ h = self.rr_bank.z_history()
878
+ if label not in h["cumulant"]:
879
+ return {"enabled": True, "error": f"unknown label {label}",
880
+ "labels": list(h["cumulant"])}
881
+ lab = (h["cumulant"][label] > 0).astype(int)
882
+ if lab.min() == lab.max():
883
+ return {"enabled": True, "error": "标签全为同一值, 无法算 AUC",
884
+ "label": label, "n": int(lab.size)}
885
+ r = complementarity(h["z"], lab, z_bad=self.rr_ev_z_bad)
886
+ r.update({"enabled": True, "label": label, "n": int(lab.size),
887
+ "pos_rate": float(lab.mean())})
888
+ return r
889
+
890
+ def rr_epistemic_all(self, names=None):
891
+ return {k: self.rr_epistemic(o) for k, o in self.rr_outcome.items()
892
+ if names is None or k in names}
893
+
894
+ def _rr_sync_states(self):
895
+ """把三态判定同步进台账, 并**计数退休/恢复**。
896
+
897
+ 为什么必须分开记: leo 的契约里 `当前状态` 用于**决策**,
898
+ `历史状态` 用于**审计** —— 一个 option 被判坏过几次、又恢复过几次,
899
+ 是"持续学习是否真的在发生"的直接证据, 不能被当前状态覆盖掉。
900
+ """
901
+ for k, o in self.rr_outcome.items():
902
+ st = self.rr_epistemic(o)
903
+ old = self._rr_prev_state.get(k)
904
+ if old is not None and st != old:
905
+ if st == "bad":
906
+ o["retired_n"] = o.get("retired_n", 0) + 1
907
+ elif old == "bad":
908
+ o["recovered_n"] = o.get("recovered_n", 0) + 1
909
+ self._rr_prev_state[k] = st
910
+ o["state"] = st
911
+ if self.rr_starved():
912
+ self.rr_starve_ever += 1
913
+
914
+ def _rr_settle(self, st, steps, reason, s0=None, s1=None,
915
+ r_opt=0.0, done=False):
916
+ self.rr_stats["terms"][reason] = self.rr_stats["terms"].get(reason, 0) + 1
917
+ self.rr_stats["durations"].append(int(steps))
918
+ # ★ 归属到**这个 option**(此前 `st` 收下即弃 —— 库级台账不存在)
919
+ o = self.rr_outcome.setdefault(getattr(st, "name", id(st)),
920
+ {"n": 0, "reached": 0, "steps": 0,
921
+ "name": getattr(st, "name", None),
922
+ "n_eff": 0.0, "reached_eff": 0.0,
923
+ "steps_eff": 0.0,
924
+ "adv_sum": 0.0, "adv_steps": 0.0,
925
+ "advq_sum": 0.0, "advq_n": 0.0,
926
+ "advq_sq": 0.0})
927
+ dec = self._rr_decay()
928
+ hit = 1.0 if reason == "beta_at_feature" else 0.0
929
+ # 终身计数(永不衰减, 供报告与判据门限用)
930
+ o["n"] += 1
931
+ o["steps"] += int(steps)
932
+ o["reached"] += int(hit)
933
+ # 有效计数(按 rr_forget_hl 指数遗忘; dec=1.0 时逐位等于终身计数)
934
+ o["n_eff"] = dec * o["n_eff"] + 1.0
935
+ o["reached_eff"] = dec * o["reached_eff"] + hit
936
+ o["steps_eff"] = dec * o["steps_eff"] + int(steps)
937
+ # 势函数差分(旧方案; 保留作对照)
938
+ prog = self._rr_pot(s1) - self._rr_pot(s0)
939
+ o["adv_sum"] = dec * o["adv_sum"] + prog
940
+ o["adv_steps"] = dec * o["adv_steps"] + max(1, int(steps))
941
+ # ★ SMDP 更新 + advantage 记账(新方案; 在线、无 oracle)
942
+ if s0 is not None and s1 is not None:
943
+ name = getattr(st, "name", None)
944
+ if name is not None:
945
+ j = self._rr_oindex(name)
946
+ qo = self.q_opt
947
+ assert qo is not None # _rr_oindex 已建表
948
+ g, k = float(self.q.gamma), max(1, int(steps))
949
+ # ★ bootstrap 用**势函数** Φ: 它才是"agent 现在认为走到某状态值多少"
950
+ # 的在线估计。`gvf` 时它是中性初始化独立学的状态价值,
951
+ # 没有"未试过的动作把 max 顶在初值"这个病。
952
+ boot = 0.0 if done else (g ** k) * self._rr_pot(s1)
953
+ tgt = float(r_opt) + boot
954
+ qo[s0, j] += self.q.lr * (tgt - qo[s0, j])
955
+ # ★ 只在**势函数对该状态有证据**时记账 —— 否则比的是初值的形状
956
+ if self._rr_has_base(s0):
957
+ advq = float(qo[s0, j]) - self._rr_pot(s0)
958
+ o["advq_sum"] = dec * o["advq_sum"] + advq
959
+ o["advq_sq"] = dec * o.get("advq_sq", 0.0) + advq * advq
960
+ o["advq_n"] = dec * o["advq_n"] + 1.0
961
+ else:
962
+ o["no_base"] = o.get("no_base", 0) + 1
963
+
964
+ # ── 自我校准: 检测 + 淘汰 ───────────────────────────────────────
965
+ def rr_broken(self, names=None):
966
+ """哪些 option 的**终止机制坏了**(而不是"暂时运气差")。
967
+
968
+ 判据(事前定义, 只看 option 自己的达成率, 不看主任务表现):
969
+ 启动 >= `rr_min_launches` 次 且 达成率 < `rr_calib_min_rate`
970
+
971
+ ## 为什么不是 `reached == 0`
972
+
973
+ 最初的写法是"从没达成过"(绝对零)。这是错的 —— **连 L2 的真案都
974
+ 抓不到**: L2 是 57 启动 / 1 次 goal_reached, 达成率 0.018, 不是 0。
975
+ 绝对零判据会放过它。
976
+
977
+ ## 0.5 这个线的依据(来自实测的两个簇, 不是拍脑袋)
978
+
979
+ 坏的: L2 真案 0.018 (56/57 expired) | 本轮制造器 0.250
980
+ 好的: rr[key] 1.000 (确定性 MDP + 模型导出的 π_o 应当几乎必成)
981
+ rr-zeroV 0.595 (健康但偏早停的臂)
982
+
983
+ 两个簇之间有一大段空档, 0.5 落在空档里。**确定性 MDP 上模型导出的
984
+ option 本该接近 1.0**, 明显低于 1.0 就意味着终止机制或 π_o 有问题。
985
+ """
986
+ out = set()
987
+ for k, o in self.rr_outcome.items():
988
+ if names is not None and k not in names:
989
+ continue
990
+ if o.get("n_eff", float(o.get("n", 0))) < self.rr_min_launches:
991
+ continue
992
+ bad_int = self._rr_rate(o) < self.rr_calib_min_rate
993
+ bad_adv = self._rr_adv(o) < self.rr_calib_min_adv
994
+ # advq 额外要求**基线证据够** + **显著更差**:
995
+ # ★ 判据必须是 "上置信界 < 0"(显著更差), 不是 "均值 < 0"。
996
+ # 实测量到 Q_adv = −0.0002 —— 那是**零的噪声范围之内**,
997
+ # 严格 `< 0` 会把每一个 option 都判坏(fallback 0.994)。
998
+ # 证据不足时不许处决 —— 这正是"犯错是可以原谅的"的统计版本,
999
+ # 而且它不需要任何手工 epsilon: 尺度由数据自己的方差给出。
1000
+ advq_mean = self._rr_advq_of(o)
1001
+ bad_q = (o.get("advq_n", 0.0) >= self.rr_min_launches
1002
+ and advq_mean + self.rr_ucb_c * self._rr_advq_se(o) < 0.0)
1003
+ # ★ 三态: "未知"**永远不算坏** —— 证据不足 / 奖励流为空都不处决。
1004
+ # 这是"退休必须保守"的原则: 宁可留着不确定的, 不可错杀。
1005
+ if self.rr_three_state and self.rr_epistemic(o) == "unknown":
1006
+ bad_q = False
1007
+ rule = self.rr_retire_rule
1008
+ if rule == "none": # 诊断臂: 只报数, 不淘汰
1009
+ hit = False
1010
+ elif rule == "internal":
1011
+ hit = bad_int
1012
+ elif rule == "adv":
1013
+ hit = bad_adv
1014
+ elif rule == "advq": # ★ 在线可用: 只需 agent 自己的 Q
1015
+ hit = bad_q
1016
+ elif rule == "both":
1017
+ hit = bad_int and bad_q
1018
+ elif rule == "all":
1019
+ hit = bad_int and bad_adv and bad_q
1020
+ else:
1021
+ hit = bad_int or bad_q
1022
+ if hit:
1023
+ out.add(k)
1024
+ return out
1025
+
1026
+ def rr_calib_stats(self):
1027
+ """校准台账: 每个 option 的启动数 / **当前胜任度** / 效率 + 谁被判坏了。
1028
+
1029
+ 注意 `rate` 报的是**有效达成率**(按 `rr_forget_hl` 遗忘后的当前胜任度),
1030
+ `rate_life` 才是终身平均 —— 持久化的意义就在这两者的差别上。
1031
+ """
1032
+ per = {}
1033
+ for k, o in sorted(self.rr_outcome.items()):
1034
+ ne = o.get("n_eff", float(o.get("n", 0)))
1035
+ m = self._rr_advq_of(o)
1036
+ se = self._rr_advq_se(o)
1037
+ per[k] = {
1038
+ # ── 当前判定(用于决策)──────────────────────────────
1039
+ "state": self.rr_epistemic(o),
1040
+ # ── 证据量 ──────────────────────────────────────────
1041
+ "n": o["n"],
1042
+ "n_eff": round(ne, 2),
1043
+ "advq_n": round(o.get("advq_n", 0.0), 2),
1044
+ "no_base": o.get("no_base", 0), # 有启动但没进比较的次数
1045
+ # ── 近期状态(遗忘加权)─────────────────────────────
1046
+ "rate": round(self._rr_rate(o), 4),
1047
+ "advq": round(m, 4),
1048
+ "advq_se": round(se, 4),
1049
+ "advq_lo": round(m - self.rr_ucb_c * se, 4), # 下置信界
1050
+ "advq_hi": round(m + self.rr_ucb_c * se, 4), # 上置信界
1051
+ "eff": round(self._rr_eff(o), 4),
1052
+ "adv": round(self._rr_adv(o), 4),
1053
+ # ── 历史状态(用于审计; 与当前状态分开)────────────
1054
+ "reached": o["reached"],
1055
+ "rate_life": round(o["reached"] / o["n"], 4) if o["n"] else 0.0,
1056
+ "retired_n": o.get("retired_n", 0), # 被判坏过几次
1057
+ "recovered_n": o.get("recovered_n", 0), # 从坏恢复过几次
1058
+ "mean_steps": round(o["steps"] / o["n"], 2) if o["n"] else 0.0,
1059
+ }
1060
+ broke = self.rr_broken()
1061
+ states = [v["state"] for v in per.values()]
1062
+ return {
1063
+ "per_option": per, "broken": sorted(broke), "n_broken": len(broke),
1064
+ # ★ 三态汇总 + 自欺门的状态 —— 报告层必须能一眼看出
1065
+ # "这次是真的没问题" 还是 "这次根本没数据"。
1066
+ "states": {"good": states.count("good"),
1067
+ "bad": states.count("bad"),
1068
+ "unknown": states.count("unknown")},
1069
+ "starved": bool(self.rr_starved()),
1070
+ # ★ 阶段五: 证据库 —— 报的是**引用清单**不是分数
1071
+ "evidence": (self.rr_ev_support() if self.rr_evidence else {"enabled": False}),
1072
+ "since_rew": int(self.rr_since_rew),
1073
+ "starve_steps": int(self.rr_starve_steps),
1074
+ "starve_ever": int(self.rr_starve_ever),
1075
+ "sel_starved": self.rr_stats.get("starved_sel", 0),
1076
+ "sel_unk_probe": self.rr_stats.get("unk_probe", 0),
1077
+ "sel_forgiven": self.rr_stats.get("forgiven", 0),
1078
+ }
1079
+
1080
+ def _rr_usage(self):
1081
+ """各 option 的**使用占比**(校准的直接观测量)。"""
1082
+ tot = sum(o["n"] for o in self.rr_outcome.values())
1083
+ if not tot:
1084
+ return {}
1085
+ return {k: round(o["n"] / tot, 4) for k, o in sorted(self.rr_outcome.items())}
1086
+
1087
+ # ── 台账 ────────────────────────────────────────────────────────
1088
+ def rr_bookkeeping(self):
1089
+ d = list(self.rr_stats["durations"])
1090
+ base = self.rr_stats["stall_baseline"]
1091
+ return {
1092
+ "recomputes": self.rr_stats["recomputes"],
1093
+ "selections": self.rr_stats["selections"],
1094
+ "exec_steps": self.rr_stats["exec_steps"],
1095
+ "terms": dict(self.rr_stats["terms"]),
1096
+ "rebuilds": self.rr_stats["rebuilds"],
1097
+ "n_options": 0 if self.rr_lib_obj is None else len(self.rr_lib_obj),
1098
+ "mean_duration": float(np.mean(d)) if d else 0.0,
1099
+ "max_duration": int(np.max(d)) if d else 0,
1100
+ "horizon": int(self.mdp.horizon),
1101
+ # ★ lock-in 检: 平均执行时长接近整回合 = 不是抽象而是锁死。
1102
+ # 阈值取自 option 自身名义时长, 所以也一并报出来。
1103
+ "lockin_ratio": (float(np.mean(d)) / max(1, self.mdp.horizon)) if d else 0.0,
1104
+ "max_cap": (int(max(self._rr_cap.values())) if self._rr_cap else 0),
1105
+ "stall_trigger_frac": (float(np.sum([1 for b in base if b >= self.stall_patience])) / max(1, len(base))) if base else 0.0,
1106
+ "beta_nonempty": 0 if self.rr_lib_obj is None else
1107
+ int(sum(int(np.sum(o.beta)) for o in self.rr_lib_obj)),
1108
+ }
hibs_lnn/rr_options.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Reward-respecting subtask options on `KeyDoorMDP`(OaK 的第一步)。
3
+
4
+ 论文:Sutton et al. 2022, *Reward-Respecting Subtasks for Model-Based
5
+ Reinforcement Learning*(arXiv:2202.03466)。
6
+
7
+ ## 层序(不可颠倒, 见 `docs/oak_diagnosis_from_papers.md`)
8
+
9
+ SubTask ──> Option ──> Option model ──> Planning
10
+ (c, z) (π_o,β_o) SMDP model planner
11
+
12
+ 本项目此前一直从**转移图**出发(bottleneck / 介数中心性 / 最短路),那正是论文
13
+ 点名的**最差类别**:"option models from ... shortest path options based on
14
+ bottleneck states ... are much less likely to be useful in planning"。
15
+ 这个模块把驱动层换回 **SubTask**。
16
+
17
+ ## 子任务的定义(论文式(4))
18
+
19
+ c(s,a) = R(s,a) ← **与主任务完全相同的奖励**
20
+ z_i(s) = V_main(s) + (w̄_i − w_i)·x_i(s) ← 只在 x_i(s)=1 处允许停
21
+ └────────── stopping bonus ──────────┘
22
+
23
+ `w_i` 是**特征 i 在主任务价值函数里的权重**(一热特征下 = 该特征为 1 诸状态上
24
+ `V_main` 的均值),不是常数 1。硬写 `w_i = 1` 会让 bonus 恒 0 → `z ≡ V_main`
25
+ → `β_o` 处处为空 → option 永不停止 → 与 shortest-path 给出逐位相同的曲线。
26
+ """
27
+
28
+ import numpy as np
29
+
30
+ from hibs_lnn.skill_mdp import N_ACT
31
+ from hibs_lnn.subtask_options import (
32
+ STOP,
33
+ Subtask,
34
+ reward_respecting_subtask,
35
+ shortest_path_subtask,
36
+ )
37
+
38
+
39
+ # ══════════════════════════════════════════════════════════════════════
40
+ # 1. KeyDoor 的表格视图 —— 给 Subtask.solve 提供 (T, R, is_goal)
41
+ # ══════════════════════════════════════════════════════════════════════
42
+
43
+ class KeyDoorTabular:
44
+ """`KeyDoorMDP` 的**表格**表示。
45
+
46
+ KeyDoor 的动力学完全确定性, 所以 `T[s][a] = [(1.0, s')]`;唯一随 regime
47
+ 改变的是 `is_goal`(目标判定含 `goal_pos < door_pos` 的免钥匙情形)与 `R`
48
+ (进目标得 +1)。**因此本对象必须按 regime 重建**, 不能跨 regime 复用。
49
+
50
+ 接口刻意与 `subtask_options.py` 里 gridworld 的环境对象**逐字一致**
51
+ (`n_states` / `T` / `R` / `is_goal` / `gamma`), 这样 `Subtask.solve`
52
+ 与 `reward_respecting_subtask` 可以原样复用, 不必为 KeyDoor 另写一份。
53
+ """
54
+
55
+ def __init__(self, mdp, gamma=0.95):
56
+ self.gamma = float(gamma)
57
+ self.regime = tuple(mdp.regime)
58
+ self.n_pos = int(mdp.n_pos)
59
+ self.n_states = int(mdp.n_states)
60
+ key_pos, door_pos, goal_pos = self.regime
61
+
62
+ # 目标状态: 与 KeyDoorMDP._reached() 同一判据
63
+ def is_term(s):
64
+ pos, _hk, do = s // 4, (s // 2) % 2, s % 2
65
+ return pos == goal_pos and (do or goal_pos < door_pos)
66
+
67
+ self.is_goal = np.array([is_term(s) for s in range(self.n_states)],
68
+ dtype=bool)
69
+
70
+ self.T = [[None] * N_ACT for _ in range(self.n_states)]
71
+ self.R = np.zeros((self.n_states, N_ACT), dtype=float)
72
+ for s in range(self.n_states):
73
+ pos, hk, do = s // 4, (s // 2) % 2, s % 2
74
+ for a in range(N_ACT):
75
+ np_, nhk, ndo = pos, hk, do
76
+ if a == 0: # LEFT
77
+ np_ = max(0, pos - 1)
78
+ elif a == 1: # RIGHT
79
+ np_ = min(self.n_pos - 1, pos + 1)
80
+ elif a == 2: # GRAB
81
+ if pos == key_pos and not hk:
82
+ nhk = 1
83
+ elif a == 3: # OPEN
84
+ if pos == door_pos and hk and not do:
85
+ ndo = 1
86
+ ns = np_ * 4 + nhk * 2 + ndo
87
+ self.T[s][a] = [(1.0, ns)]
88
+ self.R[s][a] = 1.0 if self.is_goal[ns] else 0.0
89
+ self.regime_key = self.regime
90
+
91
+
92
+ # ══════════════════════════════════════════════════════════════════════
93
+ # 2. 特征向量与主任务价值
94
+ # ══════════════════════════════════════════════════════════════════════
95
+
96
+ def feature_vectors(mdl: KeyDoorTabular):
97
+ """KeyDoor 的**子成就特征**: 「拿到钥匙」与「打开门」。
98
+
99
+ 这两个是任务链上真正的中间成果(key → door → goal), 对应论文里
100
+ "the features are the sub-achievements of the task" 的取法。
101
+ """
102
+ n = mdl.n_states
103
+ x_key = np.array([(s // 2) % 2 for s in range(n)], dtype=float)
104
+ x_door = np.array([s % 2 for s in range(n)], dtype=float)
105
+ return {"key": x_key, "door": x_door}
106
+
107
+
108
+ def main_value(mdl: KeyDoorTabular, max_sweeps=5000, tol=1e-12):
109
+ """主任务的最优价值 `V_main`(精确价值迭代)。
110
+
111
+ ★ 与 `Subtask.solve` 用**同一套约定**: 目标状态价值为 0, 其余
112
+ `V(s) = max_a [ R(s,a) + γ V(s') ]`。两处约定一致是硬要求 ——
113
+ 否则 `z_i(s) = V_main(s) + bonus` 里的 `V_main` 与子任务自身
114
+ 的价值不在同一把尺子上, `β_o` 会在错误的地方触发。
115
+ """
116
+ n = mdl.n_states
117
+ V = np.zeros(n)
118
+ for _ in range(max_sweeps):
119
+ delta = 0.0
120
+ Vn = V.copy()
121
+ for s in range(n):
122
+ if mdl.is_goal[s]:
123
+ Vn[s] = 0.0
124
+ continue
125
+ best = -np.inf
126
+ for a in range(N_ACT):
127
+ if mdl.T[s][a] is None:
128
+ continue
129
+ val = mdl.R[s][a] + mdl.gamma * V[mdl.T[s][a][0][1]]
130
+ if val > best:
131
+ best = val
132
+ Vn[s] = best
133
+ delta = max(delta, abs(Vn[s] - V[s]))
134
+ V = Vn
135
+ if delta < tol:
136
+ break
137
+ return V
138
+
139
+
140
+ def optimal_policy(mdl: KeyDoorTabular):
141
+ """主任务最优策略(诊断用: 给 T_adapt 阈值与 sanity check)。"""
142
+ V = main_value(mdl)
143
+ n = mdl.n_states
144
+ pol = np.full(n, STOP, dtype=int)
145
+ for s in range(n):
146
+ if mdl.is_goal[s]:
147
+ continue
148
+ best_a, best_v = STOP, -np.inf
149
+ for a in range(N_ACT):
150
+ val = mdl.R[s][a] + mdl.gamma * V[mdl.T[s][a][0][1]]
151
+ if val > best_v:
152
+ best_a, best_v = a, val
153
+ pol[s] = best_a
154
+ return V, pol
155
+
156
+
157
+ # ══════════════════════════════════════════════════════════════════════
158
+ # 3. 子任务库的构造
159
+ # ══════════════════════════════════════════════════════════════════════
160
+
161
+ def build_rr_library(mdl: KeyDoorTabular, V_main, bonus_weight=1.0,
162
+ feats=("key", "door"), cumulant="reward",
163
+ z_mode="rr"):
164
+ """构造 reward-respecting 子任务库并**解出** option。
165
+
166
+ 参数就是消融的旋钮 —— 每个旋钮对应矩阵里的一个臂:
167
+
168
+ `z_mode` "rr" → `z = V_main + (w̄ − w_i)x_i` (机制本体)
169
+ "zeroV" → `z = 0 + (w̄ − 0 )x_i` (削掉价值函数)
170
+ "nobonus" → `w̄ = w_i` → bonus ≡ 0 (削掉停止奖励)
171
+ `cumulant` "reward" → `c = R`(reward-respecting)
172
+ "zero" → `c = 0`(纯"到达特征", 丢掉沿途目标)
173
+ """
174
+ feats_all = feature_vectors(mdl)
175
+ lib = []
176
+ for name in feats:
177
+ x = feats_all[name]
178
+ if z_mode == "rr":
179
+ st = reward_respecting_subtask(mdl, x, V_main,
180
+ bonus_weight=bonus_weight)
181
+ elif z_mode == "zeroV":
182
+ # 削掉价值函数: V_main ≡ 0。此时 z 只剩 bonus 项,
183
+ # "停"与"继续"不再由主任务价值定价 —— 这正是用户猜测的
184
+ # "可能是价值函数的原因" 的**直接检验臂**。
185
+ z0 = np.zeros_like(np.asarray(V_main, dtype=float))
186
+ st = reward_respecting_subtask(mdl, x, z0,
187
+ bonus_weight=bonus_weight)
188
+ elif z_mode == "nobonus":
189
+ # w̄ = w_i → bonus ≡ 0 → z ≡ V_main → 论文警告的退化情形。
190
+ # 这一臂**预期**表现为 option 永不提前停(β_o 空), 是 harness
191
+ # 的"已知负向"校准点(Pitfall 35 / §9.3 第三条)。
192
+ on = np.asarray(x, dtype=float) > 0
193
+ wi = float(np.mean(np.asarray(V_main, dtype=float)[on])) if on.any() else 0.0
194
+ st = reward_respecting_subtask(mdl, x, V_main, bonus_weight=wi)
195
+ else:
196
+ raise ValueError(f"unknown z_mode: {z_mode}")
197
+
198
+ if cumulant == "zero":
199
+ st.c_fn = (lambda s, a: 0.0) # 丢掉沿途主任务奖励
200
+ elif cumulant != "reward":
201
+ raise ValueError(f"unknown cumulant: {cumulant}")
202
+
203
+ st.name = f"rr[{name}]"
204
+ st.feature = x
205
+ st.solve(mdl)
206
+ lib.append(st)
207
+ return lib
208
+
209
+
210
+ def build_bottleneck_library(mdl: KeyDoorTabular, subgoal_state=None):
211
+ """**已知弱基线(负对照)**: 从瓶颈/子目标状态出发的 shortest-path subtask。
212
+
213
+ `c = −1`, `z = 0` 只在子目标处 —— 它优化的是"**到达**",完全无视任务目标。
214
+ 它在这里的**正确行为就是不比什么都不做好**, 所以它同时是 harness 的
215
+ 负向校准点(§9:一个已知负向的臂必须复现为负)。
216
+ """
217
+ if subgoal_state is None:
218
+ # 瓶颈 = 门所在的格(拿钥匙后必须经过) —— 或用割点思想选
219
+ door_pos = mdl.regime[1]
220
+ subgoal_state = min(mdl.n_states - 1, door_pos * 4 + 2) # (door, has_key, closed)
221
+ st = shortest_path_subtask(mdl, subgoal_state)
222
+ st.name = f"bottleneck[{subgoal_state}]"
223
+ st.feature = np.zeros(mdl.n_states)
224
+ st.solve(mdl)
225
+ return [st]
226
+
227
+
228
+ def build_random_goal_library(mdl: KeyDoorTabular, V_main, seed=0,
229
+ n_goals=2, bonus_weight=1.0):
230
+ """**随机目标(负对照)**: 目标状态随机取, 其余同 reward-respecting。
231
+
232
+ 论文 Fig.1 的第三条曲线。用来检验"收益是不是来自目标**选得好**,
233
+ 而不是来自'有一个可停止的子任务'这件事本身"。
234
+ """
235
+ rng = np.random.RandomState(seed)
236
+ lib = []
237
+ cand = [s for s in range(mdl.n_states) if not mdl.is_goal[s]]
238
+ for i in range(n_goals):
239
+ g = int(cand[rng.randint(len(cand))])
240
+ x = np.zeros(mdl.n_states)
241
+ x[g] = 1.0
242
+ st = reward_respecting_subtask(mdl, x, V_main,
243
+ bonus_weight=bonus_weight)
244
+ st.name = f"random[{g}]"
245
+ st.feature = x
246
+ st.solve(mdl)
247
+ lib.append(st)
248
+ return lib
249
+
250
+
251
+ # ══════════════════════════════════════════════════════════════════════
252
+ # 4. 启动集 I_o 与选择
253
+ # ══════════════════════════════════════════════════════════════════════
254
+
255
+ def initiation_ok(st: Subtask, s: int) -> bool:
256
+ """`I_o(s)`: 只有当**特征尚未达成**且**在 s 停止不是最优**时才允许启动。
257
+
258
+ "特征已达成"= `x_i(s) > 0`(如 has_key=1)。已经拿到钥匙还去执行
259
+ "拿钥匙"的 option 是空转;论文的 initiation set 正是排除这类状态。
260
+ """
261
+ x = getattr(st, "feature", None)
262
+ if x is None:
263
+ return True
264
+ return float(x[s]) <= 0.0
265
+
266
+
267
+ def option_gain(st: Subtask, s: int, V_main) -> float:
268
+ """选择用的**增益**: 该 option 承诺的价值超出主任务价值多少。
269
+
270
+ 只在可启动处有意义;不可启动返回 `-inf`。选择 = `argmax` 这个量,
271
+ 这就是"哪个子任务现在最值得追"。
272
+ """
273
+ if st.value is None or not initiation_ok(st, s):
274
+ return -np.inf
275
+ v = float(st.value[s])
276
+ if not np.isfinite(v):
277
+ return -np.inf
278
+ return v - float(np.asarray(V_main)[s])
hibs_lnn/skill_agent.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """skill_agent.py — 在 KeyDoorMDP 上做 B 矩阵的智能体。
2
+
3
+ ## 要回答的问题 (用户 2026-09-14 框架里的 H5 + 复用)
4
+
5
+ "Option 是否保存了可以重新利用的经验?"
6
+
7
+ 即 A→B→C→A→B→C 反复漂移时, 是否出现
8
+
9
+ T_A^(2) < T_A^(1), T_A^(3) < T_A^(2)
10
+
11
+ ## 公平对照的两个设计要点
12
+
13
+ 1. **两个 agent 都保留 Q 表** (跨 regime 不重置)
14
+ 否则"复用"测的是"有没有记忆", 而不是"option 有没有带来额外的可复用结构"。
15
+ 我们要隔离的是 **option 库** 的额外贡献。
16
+
17
+ 2. **相同计算预算**: option agent 的额外开销是"拟合世界模型 + 发现 option",
18
+ 所以给它和 primitive 相同数量的**环境交互**。
19
+ 测的是 **样本效率 R(N)**, 不是"跑得更久所以更好"。
20
+
21
+ ## 关键: regime 变化后 option 库会不会失效?
22
+
23
+ 一个 regime = (key_pos, door_pos, goal_pos)。换 regime 后:
24
+ - **技能本身仍然成立** ("走到 key 位置并抓取" 这个**行为模式**没变)
25
+ - 但**地点变了** -> 旧 option 的 goal_center 是**过期的**
26
+
27
+ 所以本实现区分两种 option 使用方式:
28
+ - `reuse_stale` : 直接用旧 option (预期: 会被过期目标拖累)
29
+ - `rediscover` : 换 regime 后**重新发现**, 但库不清空 (预期: 应该更快)
30
+ 这正好对应"技能可复用"的两种理解, 也是判据所在。
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ import numpy as np
38
+
39
+ ROOT = Path(__file__).resolve().parents[1]
40
+ if str(ROOT) not in sys.path:
41
+ sys.path.insert(0, str(ROOT))
42
+
43
+ from hibs_lnn.skill_mdp import N_ACT, KeyDoorMDP # noqa: E402
44
+ from hibs_lnn.uncertainty_gate import TabularTransition # noqa: E402
45
+
46
+
47
+ class QLearner:
48
+ """表格 Q-learning (跨 regime 保留 Q 表 —— 这是"记忆"基线)。"""
49
+
50
+ def __init__(self, n_states, n_act=N_ACT, lr=0.2, gamma=0.95,
51
+ eps=0.15, seed=0, q_init=1.0):
52
+ # ★ **乐观初始化** —— 稀疏奖励下这是必需的, 不是调参技巧。
53
+ # 若 Q 全为 0, `argmax` 恒返回动作 0, agent 会**一直做同一个动作**
54
+ # (实测: 在 KeyDoor 上一直 left, 卡在 pos 0), 只有 ε-随机步才动,
55
+ # 于是几乎永远到不了目标 -> 600 回合成功率仍为 0.00。
56
+ # 初值取正 (q_init=+1) 使每个 (s,a) 都先被尝一次, 由更新把它拉回真值。
57
+ self.Q = np.full((n_states, n_act), float(q_init))
58
+ self.n_states, self.n_act = n_states, n_act
59
+ self.lr, self.gamma, self.eps = lr, gamma, eps
60
+ self.q_init = float(q_init)
61
+ self.rng = np.random.RandomState(seed)
62
+
63
+ def act(self, s):
64
+ if self.rng.rand() < self.eps:
65
+ return int(self.rng.randint(0, self.n_act))
66
+ return int(np.argmax(self.Q[s]))
67
+
68
+ def update(self, s, a, r, s2, done):
69
+ tgt = r if done else r + self.gamma * float(np.max(self.Q[s2]))
70
+ self.Q[s, a] += self.lr * (tgt - self.Q[s, a])
71
+
72
+
73
+ class SkillAgent:
74
+ """Q-learning + 一个**跨 regime 保留**的技能库。"""
75
+
76
+ def __init__(self, mdp: KeyDoorMDP, mode="primitive", seed=0,
77
+ discover_every=25, min_trans=120, opt_prob=0.5,
78
+ use_subgoals=1, n_regions=16, max_len=5, term_eps=None):
79
+ self.mdp = mdp
80
+ self.mode = mode # primitive / reuse_stale / rediscover / macro
81
+ self.q = QLearner(mdp.n_states, seed=seed)
82
+ self.rng = np.random.RandomState(seed + 1)
83
+ self.discover_every = int(discover_every)
84
+ self.min_trans = int(min_trans)
85
+ self.opt_prob = float(opt_prob)
86
+ self.use_subgoals = int(use_subgoals)
87
+ self.n_regions = int(n_regions)
88
+ self.max_len = int(max_len)
89
+ # ★ β_o 的"目标达成"阈值必须与**状态空间尺度**匹配。
90
+ # KeyDoor 的 vec() = one-hot(pos) ⊕ has_key ⊕ door_open (n_pos+2 维)。
91
+ # **两个不同位置之间的 L2 距离是 √2 ≈ 1.414**, 而到达"某个目标区域"
92
+ # 的合理判据是"落进该区域", 该区域半径就是 OptionManager 的
93
+ # `init_radius`(按区域最近邻距离中位数 * 0.75 自适应推断)。
94
+ # 固定 eps=0.05 (给"归一化距离"用的量级) 在这里**几乎永不满足**
95
+ # -> β_o 只在预算耗尽时触发 -> success 恒 0 -> 技能库从不学习。
96
+ # 这是本项目第 5 次同型错误("阈值与状态空间尺度不匹配"),
97
+ # 故此处**默认 None = 运行时取 om.init_radius**, 不再手写常数。
98
+ self.term_eps = None if term_eps is None else float(term_eps)
99
+
100
+ self.trans = [] # 全历史 (跨 regime 累积)
101
+ self.om = None # 当前技能库
102
+ self.episodes = 0
103
+ self.n_discover = 0
104
+
105
+ # ── 技能库维护 ──────────────────────────────────────────────────
106
+ def maybe_discover(self, force=False):
107
+ """周期性 (或强制) 用**已有全部转移**重建技能库。
108
+
109
+ ★ 库**不清空**: 新 options 与旧 options 合并去重。
110
+ 这才是"技能可复用"的实现 —— 换 regime 只是新增地点变体,
111
+ 而不是从头学。
112
+ """
113
+ if not force:
114
+ if self.episodes % self.discover_every != 0:
115
+ return
116
+ if len(self.trans) < self.min_trans:
117
+ return
118
+ X = np.array([np.append(v, a) for v, a, _ in self.trans])
119
+ Y = np.array([vn for _, _, vn in self.trans])
120
+ dim = X.shape[1] - 1
121
+ T = TabularTransition().fit(X, Y, N_ACT)
122
+ from hibs_lnn.option_manager import OptionManager
123
+ from hibs_lnn.uncertainty_gate import UncertaintyGate
124
+ g = UncertaintyGate(tau_C=1.0)
125
+ g.calibrate([T.predict(X[i, :dim], int(X[i, -1]))[1]
126
+ for i in range(0, len(X), max(1, len(X) // 100))])
127
+ om = OptionManager(T, g, max_len=self.max_len, seed=0)
128
+ n_uniq = len(np.unique(np.round(X[:, :dim], 6), axis=0))
129
+ nreg = min(self.n_regions, max(4, n_uniq))
130
+ if self.use_subgoals:
131
+ om.discover_subgoals(X[:, :dim], X[:, -1].astype(int), n_regions=nreg)
132
+ else:
133
+ om.discover(X[:, :dim], X[:, -1].astype(int), n_regions=nreg)
134
+ if self.om is not None:
135
+ # 合并: 旧技能在前 (先验偏好), 新技能去重后追加
136
+ seen = {tuple(int(x) for x in o.actions) for o in self.om.options}
137
+ for o in om.options:
138
+ k = tuple(int(x) for x in o.actions)
139
+ if k not in seen:
140
+ self.om.options.append(o)
141
+ seen.add(k)
142
+ else:
143
+ self.om = om
144
+ self.n_discover += 1
145
+
146
+ def _select_option(self, v):
147
+ """选一个可启动的 option(与 _option_action 同源, 抽出复用)。"""
148
+ if self.om is None or not self.om.options:
149
+ return None
150
+ goal_vec = None
151
+ try:
152
+ key_pos, door_pos, goal_pos = self.mdp.regime
153
+ gv = np.zeros_like(v)
154
+ gv[goal_pos] = 1.0
155
+ gv[self.mdp.n_pos] = 1.0 # has_key = 1
156
+ gv[self.mdp.n_pos + 1] = 1.0 # door_open = 1
157
+ goal_vec = gv
158
+ except Exception:
159
+ pass
160
+ return self.om.select(v, goal=goal_vec, exclude_unc=True)
161
+
162
+ def _option_action(self, s):
163
+ """按 option 出动作 —— **必须尊重启动集 I_o**。
164
+
165
+ ★ 原来的写法是「从技能库里**均匀随机**挑一个 option, 取它的第一个动作」。
166
+ 这既不看 `I_o`(启动集), 也不管目标是否相关, 等于往动作里注入
167
+ 一半的噪声。实测后果 (KeyDoor B 矩阵):
168
+ 技能库涨到 54~60 个后, rediscover 在全部 6 段上都差于 primitive,
169
+ 其中两段**永不收敛** (T_adapt = 上限 300)。
170
+
171
+ 正确做法 (用户给的 `Option=(I_o,g_o,π_o,β_o)`):
172
+ 用 `OptionManager.select(s, goal=...)` —— 它**会**检查 `initiable(s)`,
173
+ 也就是"当前状态是否落在该 option 的启动集里"。这才是 `I_o` 的语义。
174
+
175
+ ★★ 本函数是**逐步顾问**语义 (每步问一次, 执行一个 primitive 动作)。
176
+ 它不是时间抽象 —— option 的多步结构完全没用上, 成败也无从统计
177
+ (`Option.visits/success` 因此永远是 0, `select()` 里
178
+ `rate = success/visits` 恒取默认 0.5, 技能库**从不学习哪些 option 管用**)。
179
+ 真正的宏执行见 `_macro_step`。
180
+ """
181
+ o = self._select_option(self.mdp.vec())
182
+ if o is None:
183
+ return None # 没有可启动的技能 -> 交回 Q (这才是正确的退让)
184
+ acts = [int(x) for x in o.actions]
185
+ if not acts:
186
+ return None
187
+ # 闭环: 用世界模型算"朝该 option 目标前进最多"的动作; 失败则退回动作序列
188
+ a = None
189
+ if self.om.T is not None:
190
+ a = self._goal_action(self.mdp.vec(), o.goal_center)
191
+ if a is None:
192
+ a = acts[0]
193
+ return int(a)
194
+
195
+ def _macro_step(self, v, active, steps):
196
+ """把一个 option 当**宏动作**执行。
197
+
198
+ 返回 `(动作, 新的 active, 新的 steps)`。`active is None` 表示本步先选一个
199
+ option 再出动作; 每步都重算 `π_o` (闭环), 当 `β_o` 命中
200
+ (目标达成 / 步数上限) 时结算成败并交回 base policy。
201
+
202
+ ## 为什么要这个
203
+
204
+ 原 `rediscover` 把 option 当**单步动作顾问**: 每步按概率问一次 "现在该做
205
+ 什么动作", option 的 `(I_o, g_o, π_o, β_o)` 里只有 `π_o` 的一步被用到。
206
+ 后果有两个, 都是实测到的:
207
+
208
+ 1. **成败无从统计** -> `visits/success` 恒 0 -> `select()` 的
209
+ `rate = success/visits` 恒为默认 0.5 -> 54~60 个 option 在启动集���
210
+ **同分**, 选择等于抛硬币 -> 全部 6 段都差于 primitive。
211
+ 2. **不是时间抽象** -> 逐步咨询等于给 base policy 注入噪声,
212
+ 没有任何"一段行为被当作一个决策单位"的效果。
213
+ """
214
+ # 目标达成阈值: 未显式给定时, 用 OptionManager 自适应推断的
215
+ # `init_radius`(区域尺度) —— 同一把尺子量启动集与目标区。
216
+ eps = self.term_eps
217
+ if eps is None:
218
+ eps = float(getattr(self.om, "init_radius", 0.75))
219
+ if active is None:
220
+ active = self._select_option(v)
221
+ steps = 0
222
+ if active is None:
223
+ return None, None, 0
224
+ a = self._goal_action(v, active.goal_center) if self.om.T is not None else None
225
+ if a is None:
226
+ a = int(active.actions[0]) if active.actions else None
227
+ steps += 1
228
+ # β_o: 目标达成 或 预算耗尽 -> 结算
229
+ # ★ 主判据 = **区域归属** (in_goal_region): "已进入目标区域"。
230
+ # 旧判据 `goal_distance(v) < eps` 在离散状态空间里退化 ——
231
+ # 实测 2737 次执行中 0 次因它触发 (eps=0.754 只接受精确到达,
232
+ # 而可达的最小距离中位数是 1.000)。距离判据保留为兜底。
233
+ try:
234
+ reached = bool(active.in_goal_region(v))
235
+ if not reached:
236
+ reached = active.goal_distance(v) < eps
237
+ except Exception:
238
+ reached = False
239
+ if reached or steps >= max(1, int(self.max_len)):
240
+ active.visits += 1
241
+ if reached:
242
+ active.success += 1
243
+ active = None
244
+ steps = 0
245
+ return a, active, steps
246
+
247
+ def _goal_action(self, v, g):
248
+ """π_o(s) = argmax_a [ ‖g−s‖ − ‖g−T(s,a)‖ ], 每步重算。"""
249
+ g = np.asarray(g, dtype=float)
250
+ d_now = float(np.linalg.norm(v - g))
251
+ best_a, best_gain = None, -np.inf
252
+ for a in range(N_ACT):
253
+ sn, unc = self.om.T.predict(v, a)
254
+ if not np.isfinite(unc):
255
+ continue
256
+ gain = d_now - float(np.linalg.norm(np.asarray(sn, dtype=float) - g))
257
+ if gain > best_gain:
258
+ best_a, best_gain = a, gain
259
+ return best_a
260
+
261
+ # ── 一个 episode ────────────────────────────────────────────────
262
+ def run_episode(self):
263
+ s = self.mdp.reset()
264
+ done = False
265
+ n = 0
266
+ # ── macro 模式的跨步状态: 当前正在执行的宏动作 ──────────────
267
+ active, o_steps = None, 0
268
+ while not done and n < self.mdp.horizon:
269
+ v = self.mdp.vec()
270
+ a = None
271
+ if self.mode == "macro":
272
+ # 宏执行: 选一个 option -> 跑到 β_o 触发 -> 结算成败
273
+ if self.rng.rand() < self.opt_prob or active is not None:
274
+ a, active, o_steps = self._macro_step(v, active, o_steps)
275
+ elif self.mode != "primitive" and self.rng.rand() < self.opt_prob:
276
+ # rediscover / reuse_stale: 逐步顾问语义 (保持不变, 作为对照)
277
+ a = self._option_action(s)
278
+ if a is None:
279
+ active, o_steps = None, 0 # 交回 base policy 时宏动作结束
280
+ a = self.q.act(s)
281
+ s2, r, done = self.mdp.step(a)
282
+ self.q.update(s, a, r, s2, done)
283
+ self.trans.append((v, a, self.mdp.vec()))
284
+ s = s2
285
+ n += 1
286
+ self.episodes += 1
287
+ if self.mode != "primitive":
288
+ self.maybe_discover()
289
+ return bool(done and self.mdp._reached()), n
290
+
291
+ def option_bookkeeping(self):
292
+ """技能库的成败台账(诊断用: 证明 visits/success 真的被回填了)。"""
293
+ if self.om is None:
294
+ return {"n_options": 0, "n_used": 0, "total_visits": 0, "total_success": 0}
295
+ vs = [o.visits for o in self.om.options]
296
+ ss = [o.success for o in self.om.options]
297
+ return {"n_options": len(self.om.options),
298
+ "n_used": int(sum(1 for v_ in vs if v_ > 0)),
299
+ "total_visits": int(sum(vs)), "total_success": int(sum(ss))}
300
+
301
+
302
+ # ── B 矩阵: regime 链 ───────────────────────────────────────────────
303
+
304
+ DEFAULT_REGIMES = [
305
+ dict(key_pos=2, door_pos=5, goal_pos=7), # A
306
+ dict(key_pos=5, door_pos=2, goal_pos=6), # B (key/door 换位)
307
+ dict(key_pos=1, door_pos=6, goal_pos=7), # C
308
+ ]
309
+
310
+
311
+ def run_regime_chain(mode="primitive", chain=None, n_pos=8, episodes_per=400,
312
+ seed=0, thresh=0.8, window=20, **kw):
313
+ """跑 A→B→C→A→B→C 链, 返回每次 regime 的 T_adapt(达到 thresh 所需回合)。
314
+
315
+ T_adapt = 该 regime 段内, 滑动成功率首次 ��� thresh 时的回合数;
316
+ 从未达到则记为 episodes_per (上限)。
317
+ """
318
+ chain = chain or (DEFAULT_REGIMES * 2)
319
+ mdp = KeyDoorMDP(n_pos=n_pos, horizon=30, **chain[0])
320
+ agent = SkillAgent(mdp, mode=mode, seed=seed, **kw)
321
+ out = []
322
+ for ci, reg in enumerate(chain):
323
+ mdp.set_regime(reg["key_pos"], reg["door_pos"], reg["goal_pos"])
324
+ if mode != "primitive":
325
+ agent.maybe_discover(force=True)
326
+ succ, t_adapt = [], None
327
+ for ep in range(episodes_per):
328
+ ok, _ = agent.run_episode()
329
+ succ.append(1.0 if ok else 0.0)
330
+ if t_adapt is None and len(succ) >= window:
331
+ if float(np.mean(succ[-window:])) >= thresh:
332
+ t_adapt = ep + 1
333
+ out.append({"regime": ci, "key": reg["key_pos"], "door": reg["door_pos"],
334
+ "goal": reg["goal_pos"],
335
+ "t_adapt": int(t_adapt if t_adapt is not None else episodes_per),
336
+ "final_succ": float(np.mean(succ[-window:])) if succ else 0.0,
337
+ "n_options": len(agent.om.options) if agent.om else 0})
338
+ return out, agent
hibs_lnn/skill_mdp.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """skill_mdp.py — 一个有**已知 option 结构**的 MDP, 用来判定:
2
+
3
+ (a) OptionManager 到底能不能发现并利用 option?
4
+ (b) E9/E10 的负结果 是**实现坏了**, 还是 **lm4 benchmark 的性质**?
5
+
6
+ ## 为什么需要它
7
+
8
+ lm4 的测量已经证明那个 benchmark **无时序结构**(不对称性 A ≡ 0, 动作可交换)。
9
+ 在那种环境下 option 无论怎么实现都不会有用 —— 所以 lm4 上的负结果
10
+ **无法判定实现好坏**。
11
+
12
+ 要判定实现, 必须换到一个**option 客观上应该有用**的环境。
13
+ 这就是经典的「钥匙—门」链式任务: 最优解要求一段**有严格顺序**的多步行为。
14
+ 若 OptionManager 在这里也发现不了/用不上 option, 那才是实现的问题。
15
+
16
+ ## 任务结构
17
+
18
+ state = (pos, has_key) pos ∈ {0..N-1}, has_key ∈ {0,1}
19
+ actions:
20
+ 0 left 位置 -1
21
+ 1 right 位置 +1
22
+ 2 grab 仅在 key_pos 且 !has_key 时拿到钥匙
23
+ 3 open 仅在 door_pos 且 has_key 时开门 (此后 goal 可达)
24
+
25
+ reward:
26
+ 到达 goal 且门已开 -> +1, 回合结束
27
+ 步数超过上限 -> 0, 回合结束
28
+
29
+ ★ **顺序依赖是内生的**:
30
+ - "go_right 到 key" 只在**还没拿钥匙**时有用
31
+ - "grab" 只在 key 位置有用
32
+ - "open" 只在**已拿钥匙**时有用
33
+ -> A(a,b) ≠ 0, 且不对称 (go_key → grab ≠ grab → go_key)
34
+ -> 这正是 lm4 里**完全缺失**的结构
35
+
36
+ ## regime (用于 A→B→A 复用测试)
37
+
38
+ 一个 regime = (key_pos, door_pos, goal_pos)。
39
+ 换 regime = 同样的技能 (拿钥匙/开门) 仍然有效, 但**地点变了**
40
+ -> 这正是"可复用行为结构"的定义, 也是 option 该体现价值的地方。
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import numpy as np
45
+
46
+ LEFT, RIGHT, GRAB, OPEN = 0, 1, 2, 3
47
+ N_ACT = 4
48
+
49
+
50
+ class KeyDoorMDP:
51
+ """钥匙—门 链式 MDP。`state = (pos, has_key)` 编码成整数 pos*2 + has_key。"""
52
+
53
+ def __init__(self, n_pos=8, key_pos=2, door_pos=5, goal_pos=7, horizon=30):
54
+ self.n_pos = int(n_pos)
55
+ self.regime = (int(key_pos), int(door_pos), int(goal_pos))
56
+ self.horizon = int(horizon)
57
+ # ★ state 必须包含 door_open! 它影响转移(开门后目标才可达)。
58
+ # 早先编码 pos*2+has_key **漏掉了 door_open** -> 同一 (pos,has_key)
59
+ # 在门开/门关时无法区分 -> 任务退化成 POMDP -> Q-learning 学不会
60
+ # (实测: 400 回合成功率仍为 0.0)。
61
+ # 现在: pos*4 + has_key*2 + door_open -> n_pos*4 个状态
62
+ self.n_states = self.n_pos * 4
63
+ self.t = 0
64
+ self.pos, self.has_key, self.door_open = 0, 0, 0
65
+
66
+ # ── 状态编码 ────────────────────────────────────────────────────
67
+ def state(self):
68
+ return self.pos * 4 + self.has_key * 2 + self.door_open
69
+
70
+ def decode(self, s):
71
+ s = int(s)
72
+ return s // 4, (s // 2) % 2, s % 2 # pos, has_key, door_open
73
+
74
+ def vec(self):
75
+ """给知识层用的连续表示: one-hot(pos) ⊕ has_key ⊕ door_open。"""
76
+ v = np.zeros(self.n_pos + 2)
77
+ v[self.pos] = 1.0
78
+ v[self.n_pos] = float(self.has_key)
79
+ v[self.n_pos + 1] = float(self.door_open)
80
+ return v
81
+
82
+ def set_regime(self, key_pos, door_pos, goal_pos):
83
+ self.regime = (int(key_pos), int(door_pos), int(goal_pos))
84
+
85
+ def reset(self):
86
+ self.t = 0
87
+ self.pos, self.has_key, self.door_open = 0, 0, 0
88
+ return self.state()
89
+
90
+ # ── 动力学 ──────────────────────────────────────────────────────
91
+ def step(self, a):
92
+ key_pos, door_pos, goal_pos = self.regime
93
+ self.t += 1
94
+ if a == LEFT:
95
+ self.pos = max(0, self.pos - 1)
96
+ elif a == RIGHT:
97
+ self.pos = min(self.n_pos - 1, self.pos + 1)
98
+ elif a == GRAB:
99
+ if self.pos == key_pos and not self.has_key:
100
+ self.has_key = 1
101
+ elif a == OPEN:
102
+ if self.pos == door_pos and self.has_key and not self.door_open:
103
+ self.door_open = 1
104
+ # 到达目标
105
+ done = False
106
+ r = 0.0
107
+ if self.pos == goal_pos and (self.door_open or goal_pos < door_pos):
108
+ r, done = 1.0, True
109
+ elif self.t >= self.horizon:
110
+ done = True
111
+ return self.state(), r, done
112
+
113
+ def _reached(self):
114
+ key_pos, door_pos, goal_pos = self.regime
115
+ return self.pos == goal_pos and (self.door_open or goal_pos < door_pos)
116
+
117
+ def greedy_action(self):
118
+ """这个任务的最优一步策略 (只为给 baseline/交互测量用)。"""
119
+ key_pos, door_pos, goal_pos = self.regime
120
+ if self.pos != key_pos and not self.has_key and key_pos >= 0:
121
+ return RIGHT if key_pos > self.pos else LEFT
122
+ if self.pos == key_pos and not self.has_key:
123
+ return GRAB
124
+ if not self.door_open and goal_pos >= door_pos:
125
+ if self.pos != door_pos:
126
+ return RIGHT if door_pos > self.pos else LEFT
127
+ if self.has_key:
128
+ return OPEN
129
+ return RIGHT if goal_pos > self.pos else LEFT
130
+
131
+ def optimal_steps(self):
132
+ """最优解需要多少步 (用于设 T_adapt 阈值)。"""
133
+ key_pos, door_pos, goal_pos = self.regime
134
+ n = abs(key_pos - 0) + 1 # 走到 key + grab
135
+ if goal_pos >= door_pos:
136
+ n += abs(door_pos - key_pos) + 1 # 走到 door + open
137
+ n += abs(goal_pos - door_pos)
138
+ else:
139
+ n += abs(goal_pos - key_pos)
140
+ return n
141
+
142
+
143
+ def interaction_asymmetry(mdp, a, b, n_ep=200, seed=0):
144
+ """A(a,b) = I(a,b) − I(b,a), 用**走到目标的步数**度量。
145
+
146
+ S_seq = 从初始态做完整序列再贪心走到目标的**总步数**
147
+ I(a,b) = S_b − S_ab (先做 a 再做 b, 比只做 b 少用多少步)
148
+
149
+ ★ 为什么不能用「成功率」: 贪心策略从**任何**状态都能解出任务,
150
+ 于是成功率恒为 1.000, 前缀完全不影响 —— 测量饱和, 什么也测不出。
151
+ (这是本项目第二次踩同一个坑; 上一次是 lm4 的 `b_only = 1.0000`。)
152
+ 步数才对前缀敏感。
153
+ """
154
+ def steps(seq):
155
+ tot = 0
156
+ for _ in range(n_ep):
157
+ mdp.reset()
158
+ n = 0
159
+ done = False
160
+ for x in seq:
161
+ _, _, done = mdp.step(x)
162
+ n += 1
163
+ if done:
164
+ break
165
+ while not done and n < mdp.horizon * 3:
166
+ _, _, done = mdp.step(mdp.greedy_action())
167
+ n += 1
168
+ tot += n
169
+ return tot / n_ep
170
+
171
+ S_ab = steps([a, b]); S_ba = steps([b, a])
172
+ S_a = steps([a]); S_b = steps([b])
173
+ # 基线: 什么都不强迫
174
+ S_none = steps([])
175
+ I_ab, I_ba = S_b - S_ab, S_a - S_ba
176
+ return dict(a=a, b=b, S_none=S_none, S_a=S_a, S_b=S_b, S_ab=S_ab, S_ba=S_ba,
177
+ I_ab=I_ab, I_ba=I_ba, asym=I_ab - I_ba)
178
+
179
+
180
+ def has_temporal_structure(mdp, n_ep=200, seed=0, tol=0.5):
181
+ """对全部有序动作对测 A(a,b), 返回是否**存在**顺序依赖。"""
182
+ import itertools
183
+ rows = [interaction_asymmetry(mdp, a, b, n_ep=n_ep, seed=seed)
184
+ for a, b in itertools.permutations(range(N_ACT), 2)]
185
+ A = np.array([r["asym"] for r in rows])
186
+ frac = float((np.abs(A) > tol).mean())
187
+ return rows, A, frac
hibs_lnn/ssm_v30_3.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ V30.3: 内部纠缠 SSM 层 (减法版)
3
+ ===================================
4
+ 核心改动: 在 SSM 递推的每一步, 用当前隐藏状态的跨维度投影
5
+ 调制下一步的 A 矩阵相位。5 行代码, 无外部引擎。
6
+
7
+ 减法:
8
+ V30.1: +EntanglementEngine (后处理)
9
+ V30.2: +EntangleDecohereEngine (纠缠+退相干+熵正则)
10
+ V30.3: 只改 SSM 内部, 不加任何外部模块
11
+ """
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+
17
+
18
+ class SSM_Layer_V30_3(nn.Module):
19
+ """
20
+ V30.3 SSM 层: V29 基线 + 内部纠缠。
21
+
22
+ 在串行扫描的每一步(h_mean 计算后):
23
+ if ent_mode != 'none':
24
+ # 调 h 的相位: 纯相位调制, 不改变振幅
25
+ phase_shift = ent_mod_net(h_norm) # 跨 N 维度投影
26
+ h = h * exp(i · phase_shift) # 纯相位旋转
27
+
28
+ 纠缠特性:
29
+ - 发生在 SSM 递推内部 (每个 token 步)
30
+ - 跨 N 维度耦合 (ent_k=3 时 top-3)
31
+ - 纯相位 (不破坏已学习的振幅结构)
32
+ - 因果: 只影响未来状态, 不影响过去
33
+ """
34
+
35
+ def __init__(self, d_model, d_state=8, fiber=None, layer_idx=0,
36
+ ent_mode='none', ent_k=3, ent_strength=0.05):
37
+ super().__init__()
38
+ self.d_model = d_model
39
+ self.d_state = d_state
40
+ self.ent_mode = ent_mode
41
+ self.ent_k = ent_k
42
+ self.ent_strength = ent_strength
43
+
44
+ # 沿用 V29 架构
45
+ self.norm = nn.LayerNorm(d_model)
46
+ self.in_proj = nn.Linear(d_model, 2 * d_model)
47
+ self.conv = nn.Conv1d(d_model, d_model, kernel_size=4, groups=d_model, padding=3)
48
+ self.out_proj = nn.Linear(d_model, d_model)
49
+
50
+ # SSM 参数
51
+ self.ssm_logsigma = nn.Parameter(torch.randn(d_model, d_state) * 0.1)
52
+ self.ssm_theta = nn.Parameter(torch.randn(d_model, d_state) * 0.5)
53
+ self.dt_proj = nn.Linear(d_model, d_model)
54
+ self.B_proj = nn.Linear(d_model, d_state)
55
+ self.C_proj = nn.Linear(d_model, d_state)
56
+ self.ssm_D = nn.Parameter(torch.ones(d_model))
57
+
58
+ # V33 路由进递推 (INTACT 意图条件化): cond 调制 dt/B (读写门意图感知)
59
+ # 零初始化 → 加载 Stage1 缓存后 cond 任意值 ≡ 无条件 (无需重训 Stage1)
60
+ self.cond_dt = nn.Linear(d_model, d_model)
61
+ self.cond_b = nn.Linear(d_model, d_state)
62
+ with torch.no_grad():
63
+ self.cond_dt.weight.zero_(); self.cond_dt.bias.zero_()
64
+ self.cond_b.weight.zero_(); self.cond_b.bias.zero_()
65
+
66
+ # KL 相关 (从 V29 继承)
67
+ self.logvar_net = nn.Sequential(
68
+ nn.Linear(d_model, d_model), nn.SiLU(),
69
+ nn.Linear(d_model, d_state),
70
+ )
71
+ self.base_prior_logvar = nn.Parameter(torch.zeros(d_state))
72
+ self.fiber_prior_strength = nn.Parameter(torch.tensor(0.1))
73
+ self.register_buffer('kl_scale', torch.tensor(0.0))
74
+
75
+ # ── V30.3 内部纠缠: 极小调制网络 ──
76
+ # 输入: |h| 投影到 N 维 (跨维度耦合)
77
+ # 输出: 相位调制量 (纯相位, 不改变振幅)
78
+ if ent_mode != 'none':
79
+ self.ent_mod = nn.Linear(d_state, d_state, bias=False)
80
+ self.ent_amp = nn.Linear(d_state, 1, bias=False) # 振幅门控
81
+
82
+ self._entropy_trace = []
83
+
84
+ def compute_entropy(self, h):
85
+ B, D, N = h.shape
86
+ amp = h.abs()
87
+ rho = amp.view(B, D, N, 1) * amp.view(B, D, 1, N)
88
+ rho = rho / (rho.sum(dim=(-1, -2), keepdim=True) + 1e-10)
89
+ try:
90
+ e = torch.linalg.eigvalsh(rho).clamp(min=1e-10)
91
+ return -(e * e.log()).sum(dim=-1).mean().item()
92
+ except:
93
+ return 0.0
94
+
95
+ def forward(self, x, cond=None):
96
+ r = x
97
+ x = self.norm(x)
98
+ a, b = self.in_proj(x).chunk(2, dim=-1)
99
+ a = F.silu(self.conv(a.transpose(-1, -2))[:, :, :x.shape[1]].transpose(-1, -2))
100
+
101
+ B_, L_, D_ = a.shape
102
+ S_ = self.ssm_logsigma.shape[-1]
103
+ sigma = -self.ssm_logsigma.exp()
104
+ theta0 = self.ssm_theta
105
+ A_eff = sigma.unsqueeze(0).unsqueeze(0) + 1j * theta0.unsqueeze(0).unsqueeze(0)
106
+
107
+ if cond is not None:
108
+ # V33 路由: 意图 cond 调制 dt 与 B (读写门意图感知), 广播到每步
109
+ c_dt = self.cond_dt(cond) # (..., d)
110
+ c_b = self.cond_b(cond) # (..., d_state)
111
+ if c_dt.dim() == 1:
112
+ c_dt = c_dt.unsqueeze(0)
113
+ c_b = c_b.unsqueeze(0)
114
+ dt = F.softplus(self.dt_proj(a) + c_dt.unsqueeze(1)) + 1e-4
115
+ Bk = self.B_proj(a) + c_b.unsqueeze(1)
116
+ else:
117
+ dt = F.softplus(self.dt_proj(a)) + 1e-4
118
+ Bk = self.B_proj(a)
119
+ Ck = self.C_proj(a)
120
+ Ab = torch.exp(dt.unsqueeze(-1) * A_eff)
121
+ bk = (Ab - 1.0) / (A_eff + 1e-8) * Bk.unsqueeze(2) * a.unsqueeze(-1)
122
+
123
+ h = torch.zeros(B_, D_, S_, dtype=torch.complex64, device=Ab.device)
124
+ ys = torch.zeros(B_, L_, D_, device=a.device)
125
+
126
+ for k in range(L_):
127
+ h_mean = Ab[:, k] * h + bk[:, k]
128
+
129
+ # ── V30.3: 内部纠缠 ──────────────
130
+ if self.ent_mode != 'none' and k > 0:
131
+ # 从 |h| 导出纯相位调制 (跨 N 维度耦合)
132
+ h_norm = h_mean / (h_mean.abs() + 1e-8) # 归一化复数
133
+ h_amp = h_mean.abs() # 振幅
134
+ phase_input = h_norm.real * h_amp # (B, D, N) 振幅加权相位信号
135
+
136
+ # 跨 N 维度的线性耦合
137
+ delta_raw = self.ent_mod(phase_input) # (B, D, N)
138
+
139
+ if self.ent_k > 0 and self.ent_k < S_:
140
+ # top-k 稀疏化: 只纠缠振幅最大的维度
141
+ _, top_idx = h_amp.topk(self.ent_k, dim=-1)
142
+ mask = torch.zeros_like(delta_raw)
143
+ mask.scatter_(-1, top_idx, 1.0)
144
+ delta_raw = delta_raw * mask
145
+
146
+ # 振幅门控: 强信号有更强的纠缠, 但不改变本身振幅
147
+ amp_gate = torch.sigmoid(self.ent_amp(h_amp)) # (B, D, 1)
148
+ phase_shift = torch.tanh(delta_raw) * self.ent_strength * amp_gate
149
+
150
+ # 纯相位旋转: 不改变振幅
151
+ h = h_mean * torch.exp(1j * phase_shift)
152
+ else:
153
+ h = h_mean
154
+
155
+ y = (Ck[:, k].unsqueeze(1) * h).sum(dim=-1).real + self.ssm_D * a[:, k]
156
+ ys[:, k] = y
157
+
158
+ if self.ent_mode != 'none':
159
+ self._entropy_trace.append(self.compute_entropy(h))
160
+
161
+ out = self.out_proj(ys * F.silu(b)) + r
162
+
163
+ # 兼容 V30_CodeLoop: 保存最后一个激活
164
+ self._last_a = a[:, -1, :]
165
+
166
+ return out, 0.0 # (y, kl=0 兼容 V30_CodeLoop)
hibs_lnn/stochastic_keydoor.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """A′ 的三个环境:把 `novelty` 与 `dynamics change` 分开。
3
+
4
+ ## 为什么需要新环境
5
+
6
+ 原 `KeyDoorMDP.step()` 是**完全确定性**的(无任何随机源)。实测:所有访问过
7
+ `n>=2` 的 `(s,a)`,后继分布的最大熵**逐位为 0.000000**。于是
8
+
9
+ 转移不确定性 H(T|S,A) ≡ 0
10
+ 转移预测误差 1−p̂(s'|s,a) 的唯一非零来源是 `n<2` 分支
11
+ 稳定区域 ≈ 同样的计数
12
+
13
+ 三个语义不同的量坍缩成**同一个"我见过这个 (s,a) 几次"**。
14
+ ⇒ 那个环境对 A 类**不可辨识**,不是 A 类不行。
15
+
16
+ ## 三个环境(A′-2)
17
+
18
+ E0 KeyDoorMDP 确定性 —— 负对照, 保留不删
19
+ E1 StochasticKeyDoor 平稳随机 —— 不确定性可分离, 无 shift
20
+ E2 NonStationaryKeyDoor 非平稳(slip↑) —— ★ **可用**: 满足构型 II
21
+ FlipKeyDoor 非平稳(翻转) —— ❌ 失败构造, 保留为反例
22
+
23
+ ★ **命名与直觉相反, 以实测为准**(探针③, 逐步紧邻等长窗口):
24
+
25
+ E2 翻转: N 3.62 A1 3.39 A2 2.75 <- N 跳得比 A1 还凶, 失败
26
+ E2 slip↑: N 0.88 A1 3.10 A2 1.25 <- N 平, A1 跳, A1/A2 分歧 ✓
27
+
28
+ 我原以为"翻转(支撑集不变)"最干净、"slip↑"是反例。**测量给出相反答案**:
29
+ 决定 novelty 的不是转移的支撑集, 而是最终的**去向分布**; 翻转恰好把 agent
30
+ 送到它几乎没去过的伙伴目标。而均匀随机 slip↑ 是在**已熟悉的地盘内**
31
+ 增加不可预测性 ⇒ 访问分布不动, `p̂` 变成错的。
32
+
33
+ ## 设计要点:机制只写一份
34
+
35
+ 每个环境只实现 **`_perturb(a) -> 实际执行的动作`**, `step()` 与诊断用的
36
+ `true_transition()` **共用**它。
37
+
38
+ 第一版把机制在 `step()` 和 `true_transition()` 里各写了一遍, 后果是:
39
+ `true_transition` 里那条 `if rand < _slip_now(): a = randint(N)` 是**均匀随机**
40
+ 版本, 对 `FlipKeyDoor` 完全不对(它只翻给伙伴动作)。于是"真值"量的是另一套
41
+ 动力学, 而探针偏偏靠真值判断环境可不可辨识 —— **诊断工具自己错了,
42
+ 却会给出一个看起来正常的结论**。两份实现只要漂移一次就足够。
43
+ """
44
+ import numpy as np
45
+
46
+ from hibs_lnn.skill_mdp import KeyDoorMDP
47
+
48
+ __all__ = ["StochasticKeyDoor", "FlipKeyDoor", "NonStationaryKeyDoor",
49
+ "N_ACTIONS", "true_transition"]
50
+
51
+ N_ACTIONS = 4 # LEFT, RIGHT, GRAB, OPEN
52
+ _NEVER = 1 << 62
53
+
54
+
55
+ class StochasticKeyDoor(KeyDoorMDP):
56
+ """E1 平稳随机:`P(s'|s,a)` 全程不变。
57
+
58
+ 以 `(1-slip)` 执行意图动作, 否则执行**均匀随机**动作。
59
+ 作用 = 制造"不确定性高但**没有** regime shift"。
60
+ """
61
+
62
+ def __init__(self, slip=0.10, seed=0, **kw):
63
+ super().__init__(**kw)
64
+ self.slip = float(slip)
65
+ self.rng = np.random.RandomState(int(seed))
66
+ self.t_total = 0 # 全局步数(跨 episode) —— 子类用它定位 shift
67
+ self.shifted = False
68
+
69
+ # ── 机制(唯一实现; step 与 true_transition 共用)──────────────
70
+ def _p_now(self):
71
+ return self.slip
72
+
73
+ def _perturb(self, a):
74
+ """返回**实际执行**的动作。支撑集由子类的实现决定。"""
75
+ if self.rng.rand() < self._p_now():
76
+ return int(self.rng.randint(N_ACTIONS))
77
+ return int(a)
78
+
79
+ def step(self, a):
80
+ if not self.shifted and self.t_total >= getattr(self, "shift_at", _NEVER):
81
+ self.shifted = True
82
+ self.t_total += 1
83
+ return KeyDoorMDP.step(self, self._perturb(a))
84
+
85
+
86
+ class FlipKeyDoor(StochasticKeyDoor):
87
+ """❌ 失败构造(保留为反例):"翻转分布、不扩张支撑集"**仍然混淆 novelty**。
88
+
89
+ ## 我当初的推理(错的)
90
+
91
+ 想让 `P(s'|s,a)` 的**支撑集恒定**、只翻转权重:
92
+
93
+ P(s'|s,a) = (1-p)·1[s'=T(s,a)] + p·1[s'=T(s,c(a))]
94
+ shift 前 p = 0.10 shift 后 p = 0.90
95
+
96
+ `c(a)` 是固定的伙伴动作(LEFT<->RIGHT, GRAB<->OPEN)。推理是:
97
+ 支撑集 `{T(s,a), T(s,c(a))}` 两个 p 下都一样 ⇒ 到达的状态集合不变
98
+ ⇒ novelty 不变; 而模型学到的 `p̂` 现在是错的 ⇒ A1 跳; 熵
99
+ `H({p,1−p})` 两 regime 同值 ⇒ A2 不跳。看起来是完美的 构型 II。
100
+
101
+ ## 实测(逐步、紧邻等长窗口, 探针③)
102
+
103
+ E2 翻转: N 0.0597 -> 0.2160 (3.62) A1 0.1908 -> 0.6476 (3.39)
104
+ A2 0.1125 -> 0.3093 (2.75)
105
+ E2c slip↑: N 0.0997 -> 0.0881 (0.88) A1 0.1738 -> 0.5382 (3.10)
106
+ A2 0.1326 -> 0.1656 (1.25)
107
+
108
+ **翻转版失败**: N 跳 3.62 倍, 比 A1 还凶。
109
+
110
+ ## 为什么推理错了(值得记)
111
+
112
+ > **决定 novelty 的不是转移的支撑集, 而是最终的去向分布。**
113
+
114
+ 支撑集不变只保证"这些状态**可能**被到达"; 但翻转把 agent 在 90% 的
115
+ 步子上送到**伙伴目标** —— 那是它极少去的地方 ⇒ 它真的闯进了新领���。
116
+ 支撑集是"可能性", novelty 量的是"实际去过几次", 两者不是一回事。
117
+
118
+ ## 教训
119
+
120
+ "不扩张支撑集"看起来是把 novelty 摘掉的自然做法, 但 agent 的行为是
121
+ **状态依赖**的 —— 改了动力学就改了轨迹, 改了轨迹就改了访问分布。
122
+ 要抑制 novelty, 得直接让**访问分布**不变, 而不是让转移的支撑集不变。
123
+ `NonStationaryKeyDoor`(均匀随机 slip↑)无意中做到了这一点:
124
+ 它在**已熟悉的地盘内**增加不可预测性。
125
+ """
126
+
127
+ COMPANION = (1, 0, 3, 2) # LEFT<->RIGHT, GRAB<->OPEN
128
+
129
+ def __init__(self, p_pre=0.10, p_post=0.90, shift_at=3000, seed=0, **kw):
130
+ super().__init__(slip=p_pre, seed=seed, **kw)
131
+ self.p_pre = float(p_pre)
132
+ self.p_post = float(p_post)
133
+ self.shift_at = int(shift_at)
134
+
135
+ def _p_now(self):
136
+ return self.p_pre if self.t_total < self.shift_at else self.p_post
137
+
138
+ def _perturb(self, a):
139
+ if self.rng.rand() < self._p_now():
140
+ return int(self.COMPANION[int(a)]) # 只翻给伙伴动作
141
+ return int(a)
142
+
143
+
144
+ class NonStationaryKeyDoor(FlipKeyDoor):
145
+ """★ E2(**可用版**):均匀随机 slip 在 `shift_at` 处抬升。
146
+
147
+ E1: slip 恒定 0.10 E2: 0.10 -> 0.60 @shift_at
148
+
149
+ 它最初是被当成"反例"写下来的(我原以为它会混淆 novelty, 因为
150
+ "slip 变大 ⇒ agent 走得不一样"). 实测**正好相反**, 它是唯一同时满足
151
+ 构型 II 的构造:
152
+
153
+ N 0.0997 -> 0.0881 (0.88) ← 平坦: 没有更"新"
154
+ A1 0.1738 -> 0.5382 (3.10) ← 跳: 学到的 p̂ 现在错了
155
+ A2 0.1326 -> 0.1656 (1.25) ← 基本平 ⇒ **A1 与 A2 分歧**
156
+
157
+ 为什么它反而干净: 均匀随机 slip 在 **已熟悉的地盘内** 增加不可预测性
158
+ (agent 早就把大部分 (s,a) 走过很多遍了), 所以访问分布几乎不动;
159
+ 而 `p̂` 的**形状**变了 ⇒ 预测误差上去了。
160
+
161
+ 于是这条环境上 "变" 与 "新" 可辨识, 并且 A1/A2 读数**明显分离** ——
162
+ 这是 A 类候选第一次在同一环境上表现出不同的量。
163
+ """
164
+
165
+ def __init__(self, slip0=0.10, slip1=0.60, shift_at=3000, seed=0, **kw):
166
+ super().__init__(p_pre=slip0, p_post=slip1, shift_at=shift_at,
167
+ seed=seed, **kw)
168
+
169
+ def _perturb(self, a):
170
+ if self.rng.rand() < self._p_now():
171
+ return int(self.rng.randint(N_ACTIONS)) # 均匀随机 = 不选边
172
+ return int(a)
173
+
174
+
175
+ def true_transition(env, s, a, trials=400):
176
+ """把 `(s,a)` 的真实后继分布**数出来**(穷举环境, 非模型)。
177
+
178
+ 只用于验证"这个 benchmark 真的可辨识"(判据 ⑨′a/⑨′b)。
179
+ agent 侧一律只用在线计数表, **不碰这个函数**。
180
+
181
+ ★ 设置状态必须走 `decode()`:`s = pos*4 + has_key*2 + door_open`,
182
+ `has_key`/`door_open` 也影响转移。手写 `env.s = s` 是错的
183
+ (那个属性不存在, 而且漏掉两个标志位)。
184
+ ★ 扰动走 `env._perturb()` —— 与 `step()` **同一份实现**。E0 没有该方法,
185
+ 退化成恒等(确定性), 正好是我们要的负对照。
186
+ """
187
+ pert = getattr(env, "_perturb", None)
188
+ cnt = {}
189
+ for _ in range(int(trials)):
190
+ env.pos, env.has_key, env.door_open = env.decode(s)
191
+ env.t = 0
192
+ aa = int(a) if pert is None else int(pert(a))
193
+ s2, _, _ = KeyDoorMDP.step(env, aa)
194
+ cnt[int(s2)] = cnt.get(int(s2), 0) + 1
195
+ return cnt
hibs_lnn/subtask_options.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""subtask_options.py — **按论文顺序**实现 STOMP(arXiv 2202.03466)。
2
+
3
+ SubTask ──> Option ──> Model ──> Planning
4
+
5
+ 本文件是对本项目现有 `OptionManager` 的**修正版**。差别(逐条对齐论文):
6
+
7
+ | | 现有 `option_manager.py` | 本文件 |
8
+ |:--|:--|:--|
9
+ | 起点 | **转移图**(model-first) | **subtask**:先定 GVF 的 `(c, z)` |
10
+ | 目标 | 状态向量 `g_o` + 欧氏距离 | **stopping value `z_i(s)`**(带 stopping bonus) |
11
+ | `π_o` | 对模型贪心 argmax | 在 subtask MDP 上解出来的策略 |
12
+ | `β_o` | 逻辑回归拟合 | **stop 动作最优即停**(与论文 Fig.2 的"红色停止格"一致) |
13
+ | model | 无 | **option model**(终止分布 + 累计奖励)显式学出来 |
14
+ | 用途 | 替换 base policy 的动作 | **进 planner**,指标 = planning look-ahead 操作数 |
15
+
16
+ ## 论文的两种 subtask(必须都实现, 才有正反例)
17
+
18
+ ```
19
+ shortest-path (bottleneck) c = −1 z = 0 at subgoal
20
+ reward-respecting c = R (与主任务相同) z_i(s) = v(s) − w_i x_i(s) + w̄_i x_i(s)
21
+ \____ stopping bonus ____/
22
+ ```
23
+ 论文 Fig.1 的结论:`shortest-path` 的 planning **比 primitive 还慢**;
24
+ `reward-respecting` **明显更快**。**这两个已知结论就是本项目的 ground truth。**
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import numpy as np
29
+
30
+ from hibs_lnn.gridworld import N_ACT, UP, RIGHT, DOWN, LEFT
31
+
32
+ STOP = N_ACT # 停止动作的编号
33
+
34
+
35
+ # ══════════════════════════════════════════════════════════════════════
36
+ # 1. Subtask 规格:GVF 的 cumulant c 与 stopping value z
37
+ # ══════════════════════════════════════════════════════════════════════
38
+
39
+ class Subtask:
40
+ """一个 GVF 子任务 = (c, z)。
41
+
42
+ c(s, a) : cumulant(论文里最短路径 subtask 是 −1, reward-respecting 是主任务奖励)
43
+ z(s) : stopping value(在 s 停止时拿到的终止价值)
44
+ """
45
+
46
+ def __init__(self, name, c_fn, z_fn):
47
+ self.name = name
48
+ self.c_fn = c_fn # (s, a) -> float, 该转移的期望 cumulant
49
+ self.z_fn = z_fn # (s) -> float
50
+ self.gamma = 0.99 # 由 solve(env) 覆盖
51
+ self.policy = None # π_o:(s) -> a 或 STOP
52
+ self.value = None # 该 subtask 的价值函数
53
+ self.model = None # option model
54
+ self.beta = None # β_o(s): 是否停止
55
+
56
+ # ── 解 subtask -> 得到 option(论文 §3) ────────────────────────────
57
+ def solve(self, env, max_sweeps=2000, tol=1e-10, resume_from=None):
58
+ """在 subtask MDP 上做价值迭代(action 集 = 4 个 primitive + STOP)。
59
+
60
+ 目标: 最大化 E[ Σ γ^t c_t + γ^K z(S_K) ]
61
+ Q(s, a) = c(s,a) + γ (1−β) V(s') + γ β z(s') # β 由"停止是否最优"隐式决定
62
+ """
63
+ n = env.n_states
64
+ V = np.zeros(n) if resume_from is None else resume_from.copy()
65
+ for sweep in range(max_sweeps):
66
+ delta = 0.0
67
+ Vn = V.copy()
68
+ for s in range(n):
69
+ if env.is_goal[s]:
70
+ Vn[s] = 0.0 # goal 是终止状态(episode 结束)
71
+ delta = max(delta, abs(Vn[s] - V[s]))
72
+ continue
73
+ # 停下:s 的价值就是 z(s)
74
+ best = self.z_fn(s)
75
+ for a in range(N_ACT):
76
+ c = self.c_fn(s, a)
77
+ if c is None:
78
+ continue
79
+ val = 0.0
80
+ for p, ns in env.T[s][a]:
81
+ # 在 ns 决定:继续(value)还是停(z)
82
+ val += p * (c + env.gamma * max(V[ns], self.z_fn(ns)))
83
+ if val > best:
84
+ best = val
85
+ Vn[s] = best
86
+ delta = max(delta, abs(Vn[s] - V[s]))
87
+ V = Vn
88
+ if delta < tol:
89
+ break
90
+ self.value = V
91
+ # 导出 π_o 与 β_o
92
+ pol = np.full(n, STOP, dtype=int)
93
+ for s in range(n):
94
+ if env.is_goal[s]:
95
+ pol[s] = STOP
96
+ continue
97
+ best_a, best_v = STOP, self.z_fn(s)
98
+ for a in range(N_ACT):
99
+ c = self.c_fn(s, a)
100
+ if c is None:
101
+ continue
102
+ val = 0.0
103
+ for p, ns in env.T[s][a]:
104
+ val += p * (c + env.gamma * max(V[ns], self.z_fn(ns)))
105
+ if val > best_v + 1e-12:
106
+ best_a, best_v = a, val
107
+ pol[s] = best_a
108
+ self.policy = pol
109
+ # β_o(s) = True 表示"停在 s 是最优的"
110
+ # 判据: z(s) >= "继续走"的价值 —— 与论文 Fig.2 的红色停止格一致
111
+ cont = np.full(n, -np.inf)
112
+ for s in range(n):
113
+ if env.is_goal[s]:
114
+ continue
115
+ best = -np.inf
116
+ for a in range(N_ACT):
117
+ c = self.c_fn(s, a)
118
+ if c is None:
119
+ continue
120
+ val = 0.0
121
+ for p_, ns in env.T[s][a]:
122
+ val += p_ * (c + env.gamma * max(V[ns], self.z_fn(ns)))
123
+ best = max(best, val)
124
+ cont[s] = best
125
+ self.beta = np.array([bool(self.z_fn(s) >= cont[s] - 1e-12) for s in range(n)],
126
+ dtype=bool)
127
+ self.gamma = env.gamma
128
+ return self
129
+
130
+
131
+ # ══════════════════════════════════════════════════════════════════════
132
+ # 2. 两种 subtask 的具体定义(论文 Fig.1 的对照)
133
+ # ══════════════════════════════════════════════════════════════════════
134
+
135
+ def shortest_path_subtask(env, subgoal_state, neg_on_neg=True):
136
+ """论文:shortest-path / bottleneck option。`c = −1`,`z = 0` 只在子目标处。"""
137
+
138
+ def c(s, a):
139
+ return -1.0
140
+
141
+ def z(s):
142
+ return 0.0 if s == subgoal_state else -np.inf
143
+
144
+ st = Subtask("shortest_path", c, z)
145
+ return st
146
+
147
+
148
+ def reward_respecting_subtask(env, feature_vec, V_main, bonus_weight=1.0,
149
+ stop_at_goal=True):
150
+ r"""论文式(4):reward-respecting subtask of feature attainment。
151
+
152
+ c(s,a) = R(s,a) -- **与主任务相同的奖励**
153
+ z_i(s) = [w^T x(s) - w_i*x_i(s)] + w_bar_i*x_i(s)
154
+ = V_main(s) + (w_bar_i - w_i)*x_i(s)
155
+ \__ stopping bonus __/
156
+
157
+ *** 一个必须做对的细节(本项目在 ground-truth 基准上抓到的 bug) ***
158
+ 式(4) 里 `w_i` 是 **特征 i 在主任务价值函数里的权重**, 不是 1。
159
+
160
+ 第一版把 `w_i` 硬写成 1.0 且 `w_bar_i = 1` -> stopping bonus 恒为 0
161
+ -> `z_i(s) == V_main(s)` -> 子任务近似主任务 -> `beta_o` 处处为空
162
+ -> option 永不停止 -> option model 退化 -> reward-respecting 与
163
+ shortest-path 给出**完全相同**的 planning 曲线(实测 2145 / 2145)。
164
+
165
+ 这正是论文原话警告的情形:
166
+ "The stopping values should not equal the estimated values because then the
167
+ subtask would approximate the main task and solving it would probably add
168
+ nothing new."
169
+
170
+ 对一热特征 x_i(s) in {0,1}, 主任务线性近似下特征 i 的权重就是
171
+ **该特征为 1 的诸状态上的价值贡献** -> w_i = mean(V_main[feature > 0])。
172
+
173
+ bonus_weight 就是论文的 w_bar_i:
174
+ w_bar ~ w_i bonus 小, option 几乎不提前停
175
+ w_bar = 1 论文主用值
176
+ w_bar -> 很大 退化成 shortest-path(论文 §6 实测, w_bar=100 走最短路)
177
+ """
178
+ on = np.asarray(feature_vec, dtype=float) > 0
179
+ w_i = float(np.mean(np.asarray(V_main, dtype=float)[on])) if on.any() else 0.0
180
+
181
+ def c(s, a):
182
+ return float(env.R[s][a])
183
+
184
+ def z(s):
185
+ if not on[s] and not (stop_at_goal and env.is_goal[s]):
186
+ # 特征为 0 处**不允许停**(否则"停"与"继续"同价 -> beta 退化)
187
+ return -np.inf
188
+ return float(V_main[s]) + (bonus_weight - w_i) * float(feature_vec[s])
189
+
190
+ st = Subtask("reward_respecting", c, z)
191
+ st.w_i = w_i
192
+ st.bonus_weight = bonus_weight
193
+ return st
194
+
195
+ # ══════════════════════════════════════════════════════════════════════
196
+ # 3. Option model(论文 §4:第三步,在 option 之后)
197
+ # ══════════════════════════════════════════════════════════════════════
198
+
199
+ def build_option_model(env, opt, max_steps=200):
200
+ r"""由 option 的 (pi_o, beta_o) 推出**SMDP 形式的 option model**。
201
+
202
+ 返回 `M[s] = [(p, s_term, r_disc, k), ...]`,其中
203
+
204
+ p : 从 s 出发最终终止于 s_term 的概率
205
+ r_disc : 沿途中**已贴现**的累计奖励 sum_t gamma^t r_t
206
+ k : 平均步数
207
+
208
+ ★★ 第二个必须做对的细节(ground-truth 基准抓到的)
209
+ -------------------------------------------------
210
+ 第一版只返回未贴现的累计奖励, 且 planner 里写成 ``r + gamma*V(s_term)``
211
+ —— 即只贴现**一步**, 而不是 option 实际占用的 k 步。
212
+ -> option 被系统性高估 -> 价值迭代发散
213
+ (实测: look-ahead ��满 300000 仍未收敛, 而 primitive 只需 1716)。
214
+
215
+ SMDP 的正确形式是: Q(s, o) = E[ r_disc + gamma^k * V(s_term) ]
216
+ """
217
+ n = env.n_states
218
+ M = []
219
+ for s0 in range(n):
220
+ cur = {int(s0): (1.0, 0.0, 0.0)} # state -> (prob, 期望贴现回报, 期望步数)
221
+ term = {}
222
+
223
+ def merge(d, key, p, dr, k):
224
+ o = d.get(key)
225
+ if o is None:
226
+ d[key] = [p, p * dr, p * k]
227
+ else:
228
+ o[0] += p
229
+ o[1] += p * dr
230
+ o[2] += p * k
231
+
232
+ for _step in range(max_steps):
233
+ if not cur:
234
+ break
235
+ nxt = {}
236
+ for s, (p, dr, k) in cur.items():
237
+ if opt.beta[s] or opt.policy[s] == STOP:
238
+ merge(term, s, p, dr, k)
239
+ continue
240
+ a = int(opt.policy[s])
241
+ r_imm = float(opt.c_fn(s, a))
242
+ for pt, ns in env.T[s][a]:
243
+ pp = p * pt
244
+ merge(nxt, int(ns), pp, dr + (env.gamma ** k) * r_imm, k + 1.0)
245
+ cur = {s: (v[0], v[1] / v[0] if v[0] > 0 else 0.0,
246
+ v[2] / v[0] if v[0] > 0 else 0.0) for s, v in nxt.items()}
247
+
248
+ outs = []
249
+ for s, (psum, drw, kw) in sorted(term.items()):
250
+ if psum <= 1e-12:
251
+ continue
252
+ outs.append((float(psum), int(s), float(drw / psum), float(kw / psum)))
253
+ if not outs:
254
+ outs = [(1.0, int(s0), 0.0, 1.0)]
255
+ M.append(outs)
256
+ return M
257
+
258
+
259
+
260
+ # ══════════════════════════════════════════════════════════════════════
261
+ # 4. Planning(论文 §5):指标 = look-ahead 操作数
262
+ # ══════════════════════════════════════════════════════════════════════
263
+
264
+ def planning_curve(env, option_models, v_star, s0, tol=1e-3, max_ops=400000,
265
+ sweeps_between=1):
266
+ """带 option 的价值迭代;记录"每消耗 N 次 look-ahead 时 V(s0) 的误差"。
267
+
268
+ option_models : {name: M}, M 来自 build_option_model。空 dict = 只用 primitive。
269
+ 返回 (ops_list, err_list) —— 与论文 Fig.1 的横/纵轴一致。
270
+ """
271
+ n = env.n_states
272
+ V = np.zeros(n)
273
+ ops_list, err_list = [], []
274
+ total = 0
275
+ # 每次都从 s0 开始扫(顺序会显著影响效率, 这里固定在自然顺序, 对三种方法一致)
276
+ order = list(range(n))
277
+ while total < max_ops:
278
+ for s in order:
279
+ if env.is_goal[s]:
280
+ V[s] = 0.0
281
+ continue
282
+ best = -np.inf
283
+ # primitive
284
+ for a in range(N_ACT):
285
+ val = 0.0
286
+ for p, ns in env.T[s][a]:
287
+ val += p * (env.R[s][a] + env.gamma * V[ns])
288
+ total += 1
289
+ if val > best:
290
+ best = val
291
+ # options —— SMDP: r_disc + gamma^k * V(s_term)
292
+ for name, M in option_models.items():
293
+ for ent in M[s]:
294
+ p, s_term, r_disc, k = ent[0], ent[1], ent[2], (ent[3] if len(ent) > 3 else 1.0)
295
+ val = p * (r_disc + (env.gamma ** k) * V[s_term])
296
+ total += 1
297
+ if val > best:
298
+ best = val
299
+ V[s] = best
300
+ err = abs(V[s0] - v_star)
301
+ ops_list.append(total)
302
+ err_list.append(err)
303
+ if err < tol:
304
+ break
305
+ return ops_list, err_list
306
+
307
+
308
+ def ops_to_tolerance(env, option_models, v_star, s0, tol=0.01, max_ops=400000):
309
+ ops, errs = planning_curve(env, option_models, v_star, s0, tol=tol, max_ops=max_ops)
310
+ return ops[-1], errs[-1], ops, errs
hibs_lnn/swifttd_head.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ V31.1: SwiftTD 式 per-feature step-size 优化 (Javed et al., RLC 2024)
3
+ =====================================================================
4
+ 解决 V31.0 发现的问题: IDBD 融合的 β 通过 BPTT 外循环学 (Meta-SGD 风格),
5
+ meta 梯度 ~1e-6 学不动 (arXiv 2401.17401 Figure 4)。
6
+
7
+ SwiftTD 三件套 (全部在线, 无 BPTT) — 精确对应论文 Algorithm 1:
8
+ 1. per-feature step-size 优化: β[i] = e^{θ[i]}, IDBD 在线更新:
9
+ θ[i] += κ·δ[i]·h[i]·φ[i] (meta 更新, 在 decay/重置之前)
10
+ h[i] ← (1-β[i]·φ[i]²)⁺·h[i] + β[i]·δ[i]·φ[i] (step-size 迹)
11
+ 2. overshoot bound: correction ratio τ = Σᵢ β[i]·φ[i]²,
12
+ τ > η 时更新缩放 s = η/τ (η = 0.1, max correction ratio)
13
+ 3. step-size decay: τ > η 时 θ[i] += φ[i]²·ln(ϵ) (ϵ = 0.99 decay rate,
14
+ 论文 Algorithm 1: 对 θ 的绝对衰减, 无 β 因子; β ← β·ϵ^{φ²})
15
+ 且重置该组的迹 (论文 L524-526)
16
+
17
+ 论文关键超参: η = 0.1, ϵ = 0.99, η_min = e^-15, β 裁剪到 [ln(η_min), ln(η)] → β_max = η。
18
+
19
+ 本实现的关键适配 (记录在 wiki):
20
+ τ 按输出行 (per-row) 计算。论文的 correction ratio 定义在单个标量预测
21
+ 上 (v = w·φ, 更新后 v' = v + s·β·δ·Σφ²); 我们的 head 有 n2 = 1616 个
22
+ 标量输出 (8 响应 × 202 vocab), 每个输出是独立的预测、独立的误差。
23
+ w2 的行 u 有它自己的 τ_u = Σₖ β[u,k]·xₖ² 和缩放 s_u — 这是论文
24
+ "单预测 bound" 对多输出层的直接推广。若用全局 τ (早期版本), 1616 行
25
+ 复制结构使 τ 被维度主导 → bound 永远触发 → 更新塌缩到 ~0 (实测失败)。
26
+ 同样地, w1 按 d 行, b1/b2 是向量 (单一 τ)。
27
+
28
+ 没有特征归一化: raw 特征保持 IDBD 元梯度 O(1) 尺度 (归一化会把
29
+ per-feature 元更新稀释到 1/N, 40 步内 β 纹丝不动, 实测失败)。
30
+
31
+ 行为: β ≤ η 且 τ ≈ β̄·Σφ² ≫ η (raw 特征) → bound 常态触发 → 每行每步
32
+ 修正恰好 η·errs (10%), decay 把 β 压到 τ≈η 的预算, IDBD 在预算内把
33
+ 步长分配给有用特征 (const 特征 → β_min, 相关特征 → 维持)。这正是论文
34
+ Section 6 描述的收敛行为。
35
+
36
+ 应用对象: PLN (2 层 MLP head)。每组用"入边激活"作为 IDBD 特征 φ:
37
+ w1 (d,d) : φ = hidden[j] (SSM 输出), δ = 回传误差 g[i]
38
+ b1 (d,) : φ = 1, δ = g[i]
39
+ w2 (n2,d) : φ = x[i] (tanh 激活), δ = errs[u]
40
+ b2 (n2,) : φ = 1, δ = errs[u]
41
+ 其中 errs = p - onehot 是 CE 对 logits 的残差 (梯度)。
42
+ """
43
+
44
+ import math
45
+ import torch
46
+ import torch.nn.functional as F
47
+
48
+
49
+ class SwiftTDHead:
50
+ """
51
+ 2 层 MLP head 的 SwiftTD 步长状态机 (V31.1)。
52
+
53
+ 参数布局 (与 V31 head_forward 一致):
54
+ W = [w1 (d,d), b1 (d,), w2 (n2,d), b2 (n2,)], n2 = vocab*n_resp
55
+ x = tanh(hidden @ w1^T + b1); logits = x @ w2^T + b2 → (n_resp, vocab)
56
+
57
+ 状态:
58
+ theta: 每参数 log 步长 θ (对应 β = e^θ, 裁剪到 [β_min, β_max=η])
59
+ h : 每参数 step-size 迹 (IDBD 的 h 向量)
60
+
61
+ 超参 (论文默认):
62
+ beta_init : 初始 β (论文 1e-7 长流; 短视野用 0.01)
63
+ kappa : IDBD meta step-size θ
64
+ eta : max correction ratio η = 0.1 (bound 阈值 = β 上限)
65
+ eps : decay rate ϵ = 0.99 (bound 触发时的衰减速率, 独立超参)
66
+ """
67
+
68
+ def __init__(self, d_model, world_vocab, n_resp,
69
+ beta_init=0.01, kappa=0.05, eta=0.1, eps=0.99,
70
+ beta_min=1e-7, input_dim=None, tau_norm=None):
71
+ """
72
+ input_dim (V32 意图注入用): w1 的输入维度。
73
+ 默认 input_dim=d_model → 与 V31 布局完全一致 (向后兼容)。
74
+ d_model 始终是隐藏层宽度 (x 的维度, w2 的输入)。
75
+ tau_norm (V35.2 诊断 A): 按有效维度归一 τ。
76
+ None/False: 原版 τ = Σ β·φ² (绝对和)
77
+ 'mean' : τ = Σ β·φ² / dim (per-dim 平均 — 修复 V32+ 3d 意图
78
+ 注入后 384 维求和恒超 η → bound 常态触发 → h 迹归零
79
+ → IDBD 分化死亡, β_std 1.6e-05 的诊断根因)
80
+ """
81
+ self.d_model = d_model
82
+ self.input_dim = input_dim if input_dim is not None else d_model
83
+ self.world_vocab = world_vocab
84
+ self.n_resp = n_resp
85
+ self.n2 = world_vocab * n_resp
86
+ self.tau_norm = tau_norm
87
+ # 布局: [w1 (d, input_dim), b1 (d,), w2 (n2,d), b2 (n2,)]
88
+ self.shapes = [(d_model, self.input_dim), (d_model,),
89
+ (self.n2, d_model), (self.n2,)]
90
+ self.beta_init = beta_init
91
+ self.kappa = kappa
92
+ self.eta = eta # max correction ratio (bound 阈值)
93
+ self.eps = eps # decay rate (独立超参!)
94
+ self.beta_max = eta # 论文: β 裁剪上限 = ln(η)
95
+ self.beta_min = beta_min
96
+ self.reset_state()
97
+
98
+ # ── 状态��理 ─────────────────────────────────────────────
99
+ def reset_state(self):
100
+ self.theta = [torch.full(s, math.log(self.beta_init)) for s in self.shapes]
101
+ self.h = [torch.zeros(s) for s in self.shapes]
102
+ self.n_bound_triggers = 0
103
+ self.n_decays = 0
104
+ self.n_steps = 0
105
+
106
+ def to(self, device):
107
+ """搬移 SwiftTD 状态 (θ/h 迹) 到 device (head 是普通类, 非 nn.Module)."""
108
+ self.theta = [t.to(device) for t in self.theta]
109
+ self.h = [t.to(device) for t in self.h]
110
+ return self
111
+
112
+ def cpu(self):
113
+ return self.to('cpu')
114
+
115
+ def beta(self):
116
+ return [t.exp().clamp(self.beta_min, self.beta_max) for t in self.theta]
117
+
118
+ def step_size_stats(self):
119
+ b = torch.cat([x.reshape(-1) for x in self.beta()])
120
+ return {
121
+ 'n_params': int(b.numel()),
122
+ 'beta_mean': float(b.mean()),
123
+ 'beta_std': float(b.std()),
124
+ 'beta_min': float(b.min()),
125
+ 'beta_max': float(b.max()),
126
+ 'frac_clipped_min': float(
127
+ (b <= self.beta_min * (1 + 1e-3)).float().mean()),
128
+ 'frac_clipped_max': float(
129
+ (b >= self.beta_max * (1 - 1e-3)).float().mean()),
130
+ 'n_bound_triggers': self.n_bound_triggers,
131
+ 'n_decays': self.n_decays,
132
+ 'n_steps': self.n_steps,
133
+ }
134
+
135
+ def state(self):
136
+ """保存当前状态 (θ/h 迹), 用于 sleep 前后快照。"""
137
+ return ([t.clone() for t in self.theta], [t.clone() for t in self.h])
138
+
139
+ def load_state(self, st):
140
+ theta, h = st
141
+ for t, v in zip(self.theta, theta):
142
+ t.copy_(v)
143
+ for t, v in zip(self.h, h):
144
+ t.copy_(v)
145
+
146
+ # ── 核心: 单样本在线一步 ─────────────────────────────────
147
+ def step(self, W, hidden, x, errs):
148
+ """
149
+ 对一个样本做一步 SwiftTD 更新 (原地修改 W, 无计算图)。
150
+ W : [w1,b1,w2,b2] 当前参数
151
+ hidden: SSM 输出 (d,)
152
+ x : tanh 激活 (d,)
153
+ errs : (n2,) = p - onehot (CE 对 logits 的残差)
154
+ """
155
+ newW = self._rates(W, hidden, x, errs, graph=False)
156
+ for old, new in zip(W, newW):
157
+ old.copy_(new)
158
+ return W
159
+
160
+ def step_graph(self, W, hidden, x, errs):
161
+ """
162
+ 可微内循环一步 (V31.2 BPTT 外循环用):
163
+ 与 step() 相同的 SwiftTD 速率 (s, β 全 detached),
164
+ 但权重更新保持计算图 → 外循环梯度穿过内循环链
165
+ (RLN 特征 φ → 误差 δ → w 更新 → query loss)。
166
+ """
167
+ return self._rates(W, hidden, x, errs, graph=True)
168
+
169
+ def _rates(self, W, hidden, x, errs, graph):
170
+ """
171
+ SwiftTD 速率计算 + 权重更新 (graph=False 原地, graph=True 新张量)。
172
+ s 与 β 为 detached 速率 (bound/decay/clip 不可微, 论文 BPTT 不可用
173
+ → 直通: 速率当作给定常数, 只对 w 链与特征链反传)。
174
+ """
175
+ w1, b1, w2, b2 = W
176
+ d, din, n2 = self.d_model, self.input_dim, self.n2
177
+
178
+ # 各组特征 φ 与误差 δ (IDBD 逐元素形式)
179
+ dev = hidden.device
180
+ f_w1 = hidden.unsqueeze(0).expand(d, self.input_dim).contiguous()
181
+ f_b1 = torch.ones(d, device=dev)
182
+ f_w2 = x.unsqueeze(0).expand(n2, d).contiguous() # (n2,d)
183
+ f_b2 = torch.ones(n2, device=dev)
184
+
185
+ d_w1 = (w2.t() @ errs) * (1.0 - x * x) # (d,) 回传误差
186
+ d_w1 = d_w1.unsqueeze(1).expand(d, self.input_dim).contiguous()
187
+ d_b1 = d_w1[:, 0]
188
+ d_w2 = errs.unsqueeze(1).expand(n2, d).contiguous() # (n2,d)
189
+ d_b2 = errs
190
+
191
+ beta = self.beta()
192
+ ln_eps = math.log(self.eps) # ϵ<1 → 负; 论文 Algorithm 1: β += φ²·ln(ϵ)
193
+
194
+ def _group(w, f, dg, gi, row_tau):
195
+ """返回 (更新后的 w, 速率 s·β 用于图形更新)。"""
196
+ b = beta[gi]
197
+ # 1) meta-θ 更新 (先于 decay/reset — 论文顺序)
198
+ self.theta[gi].add_(self.kappa * dg.detach() * self.h[gi] * f.detach())
199
+ # 2) 速率: τ 与缩放 s (detached)
200
+ if row_tau:
201
+ tau = (b * f.detach() * f.detach()).sum(dim=1)
202
+ if self.tau_norm == 'mean':
203
+ tau = tau / f.shape[1] # per-dim 平均 (诊断 A)
204
+ else:
205
+ tau = (b * f.detach() * f.detach()).sum()
206
+ if self.tau_norm == 'mean' and f.dim() == 1:
207
+ tau = tau / f.shape[0] # b1/b2 向量: 按元素数归一
208
+ s = torch.ones_like(tau)
209
+ trig = tau > self.eta
210
+ if bool(trig.any()):
211
+ s = torch.where(trig, self.eta / tau, s)
212
+ # 3) decay + 迹重置 (仅触发行)
213
+ self.theta[gi][trig].add_(
214
+ ln_eps * (f.detach() * f.detach())[trig])
215
+ self.h[gi][trig].zero_()
216
+ self.n_bound_triggers += int(trig.sum())
217
+ self.n_decays += int(trig.sum())
218
+ # 4) h 更新 + clip θ → β ∈ [β_min, η] (论文 Section 6)
219
+ self.h[gi].mul_(torch.clamp(
220
+ 1.0 - b * f.detach() * f.detach(), min=0.0))
221
+ self.h[gi].add_(b * dg.detach() * f.detach())
222
+ self.theta[gi].clamp_(math.log(self.beta_min),
223
+ math.log(self.beta_max))
224
+ # 速率 (detached): s·β, 广播到 f 形状
225
+ if f.dim() == 2:
226
+ s = s.unsqueeze(1)
227
+ rate = (s * b).detach()
228
+ if graph:
229
+ return w - rate * dg * f, rate
230
+ return w - rate * dg * f, rate
231
+
232
+ w1n, _ = _group(w1, f_w1, d_w1, 0, row_tau=True)
233
+ b1n, _ = _group(b1, f_b1, d_b1, 1, row_tau=False)
234
+ w2n, _ = _group(w2, f_w2, d_w2, 2, row_tau=True)
235
+ b2n, _ = _group(b2, f_b2, d_b2, 3, row_tau=False)
236
+
237
+ self.n_steps += 1
238
+ return [w1n, b1n, w2n, b2n]
239
+
240
+ # ── 便捷封装: 单样本适应 ─────────────────────────────────
241
+ def adapt_sample(self, W, hidden, tgt, V):
242
+ """hidden: (1,d) SSM 输出; tgt: (n_resp,) 目标 token。"""
243
+ d = self.d_model
244
+ x = torch.tanh(hidden @ W[0].t() + W[1])
245
+ logits = x @ W[2].t() + W[3] # (1, n2)
246
+ logits = logits.view(1, self.n_resp, self.world_vocab)
247
+ p = F.softmax(logits, dim=-1)
248
+ onehot = F.one_hot(tgt, self.world_vocab).float()
249
+ errs = (p - onehot).reshape(-1) # (n2,)
250
+ self.step(W, hidden[0], x[0], errs.detach())
251
+ return logits
252
+
253
+
254
+ # ============================================================
255
+ # 自测 1: 论文 weight-flipping 问题 (arXiv 2401.17401 §3)
256
+ # 20 维特征, 前 15 维权重恒为 0 (该学低步长), 后 5 维每 20 步翻转 ±1
257
+ # (该学高步长)。IDBD 应学到 per-feature 步长分化, 且性能好于标量 SGD。
258
+ # 单行 = 单预测, 与模块 row_tau=True 逻辑一致。
259
+ # ============================================================
260
+ def _idbd_linear_step(w, theta, h, phi, delta, kappa, eta, eps,
261
+ beta_min, beta_max):
262
+ """单行线性预测的 SwiftTD 一步 (与模块逻辑一致, 供自测复用)。"""
263
+ b = theta.exp().clamp(beta_min, beta_max)
264
+ # 1) meta-θ 更新 (先于 decay/reset — 论文顺序)
265
+ theta.add_(kappa * delta * h * phi)
266
+ # 2) 权重更新 (bound 缩放)
267
+ tau = (b * phi * phi).sum()
268
+ s = 1.0
269
+ decayed = False
270
+ if tau > eta:
271
+ s = eta / tau
272
+ # 3) decay (绝对衰减, 无 β 因子) + 迹重置
273
+ theta.add_(math.log(eps) * phi * phi)
274
+ h.zero_()
275
+ decayed = True
276
+ w.add_(s * b * delta * phi)
277
+ # 4) h 更新
278
+ h.mul_(torch.clamp(1.0 - b * phi * phi, min=0.0))
279
+ h.add_(b * delta * phi)
280
+ theta.clamp_(math.log(beta_min), math.log(beta_max))
281
+ return w, theta, h, decayed
282
+
283
+
284
+ def _self_test_weight_flipping(seed=42, n_steps=50000, kappa=0.05,
285
+ beta_init=0.1, verbose=True):
286
+ torch.manual_seed(seed)
287
+ d = 20
288
+ w_true = torch.zeros(d)
289
+ w_true[15:] = 1.0
290
+
291
+ def run_idbd():
292
+ # 快速追踪域 (论文 Figure 5 探索范围): η=0.5, β_max=1.0
293
+ # 翻转每 20 步一次 → 每步需 ~100% 修正 → β 上限需 > η。
294
+ # MLP head 的收敛域 (β_max=η=0.1) 由自测 3 覆盖。
295
+ beta_min, beta_max, eta, eps = 1e-5, 1.0, 0.5, 0.99
296
+ theta = torch.full((d,), math.log(beta_init))
297
+ h = torch.zeros(d)
298
+ w = torch.zeros(d)
299
+ errs = []
300
+ for t in range(n_steps):
301
+ if t % 20 == 0:
302
+ idx = 15 + (t // 20) % 5
303
+ w_true[idx] *= -1
304
+ phi = torch.randn(d)
305
+ y = (w_true * phi).sum()
306
+ delta = y - (w * phi).sum()
307
+ w, theta, h, _ = _idbd_linear_step(
308
+ w, theta, h, phi, delta, kappa, eta, eps, beta_min, beta_max)
309
+ errs.append(delta.item() ** 2)
310
+ return sum(errs) / len(errs), theta.exp()
311
+
312
+ def run_scalar(alpha):
313
+ w = torch.zeros(d)
314
+ errs = []
315
+ for t in range(n_steps):
316
+ if t % 20 == 0:
317
+ idx = 15 + (t // 20) % 5
318
+ w_true[idx] *= -1
319
+ phi = torch.randn(d)
320
+ y = (w_true * phi).sum()
321
+ delta = y - (w * phi).sum()
322
+ w.add_(alpha * delta * phi)
323
+ errs.append(delta.item() ** 2)
324
+ return sum(errs) / len(errs)
325
+
326
+ mse_idbd, beta_end = run_idbd()
327
+ b_hi = beta_end[15:].mean().item()
328
+ b_lo = beta_end[:15].mean().item()
329
+ # 标量基线: 在非发散区间内扫 α, 取最优
330
+ mse_scalar = min(run_scalar(a) for a in (0.001, 0.005, 0.01, 0.02, 0.05))
331
+
332
+ if verbose:
333
+ print(f" [weight-flipping] MSE: idbd={mse_idbd:.5f} "
334
+ f"scalar_best={mse_scalar:.5f}")
335
+ print(f" β 均值: const dims={b_lo:.5f} (应低), "
336
+ f"flip dims={b_hi:.5f} (应高), 比值={b_hi / max(b_lo, 1e-9):.1f}x")
337
+
338
+ ok = (mse_idbd <= mse_scalar * 1.05 and b_hi > b_lo * 3)
339
+ print(f" {'OK' if ok else 'FAIL'} IDBD 学到 per-feature 步长 "
340
+ f"(误差更低 + 高/低步长分化)")
341
+ return ok
342
+
343
+
344
+ # ============================================================
345
+ # 自测 2: 三件套在 β_init 过大时不发散 (bound + decay + 迹重置)
346
+ # ============================================================
347
+ def _self_test_bound_stability(seed=42, n_steps=20000, beta_init=0.5,
348
+ verbose=True):
349
+ torch.manual_seed(seed)
350
+ d = 20
351
+ w_true = torch.zeros(d)
352
+ w_true[15:] = 1.0
353
+ kappa, eta, eps, beta_min, beta_max = 0.05, 0.5, 0.99, 1e-5, 1.0
354
+
355
+ def run(alpha, use_swift=False):
356
+ theta = torch.full((d,), math.log(beta_init))
357
+ h = torch.zeros(d)
358
+ w = torch.zeros(d)
359
+ errs = []
360
+ for t in range(n_steps):
361
+ if t % 20 == 0:
362
+ idx = 15 + (t // 20) % 5
363
+ w_true[idx] *= -1
364
+ phi = torch.randn(d)
365
+ y = (w_true * phi).sum()
366
+ delta = y - (w * phi).sum()
367
+ if use_swift:
368
+ w, theta, h, _ = _idbd_linear_step(
369
+ w, theta, h, phi, delta, kappa, eta, eps,
370
+ beta_min, beta_max)
371
+ else:
372
+ w.add_(alpha * delta * phi)
373
+ errs.append(delta.item() ** 2)
374
+ if math.isnan(delta.item()):
375
+ return float('inf')
376
+ return sum(errs) / len(errs)
377
+
378
+ mse_swift = run(0.0, use_swift=True)
379
+ mse_scalar = run(beta_init)
380
+ ok = math.isfinite(mse_swift) and mse_swift < mse_scalar
381
+ if verbose:
382
+ print(f" [bound 稳定性] β_init={beta_init}: "
383
+ f"scalar(α={beta_init}) MSE={mse_scalar:.5f} (应发散/爆炸), "
384
+ f"SwiftTD MSE={mse_swift:.5f}")
385
+ print(f" {'OK' if ok else 'FAIL'} bound+decay 防止发散")
386
+ return ok
387
+
388
+
389
+ # ============================================================
390
+ # 自测 3: MLP head 单步形状 + loss 下降 + β 分化
391
+ # ============================================================
392
+ def _self_test_mlp_head():
393
+ torch.manual_seed(0)
394
+ d, V, n_resp = 8, 20, 2
395
+ head = SwiftTDHead(d, V, n_resp, beta_init=0.01)
396
+ w1 = torch.randn(d, d) * 0.5
397
+ b1 = torch.zeros(d)
398
+ w2 = torch.randn(V * n_resp, d) * 0.5
399
+ b2 = torch.zeros(V * n_resp)
400
+ W = [w1, b1, w2, b2]
401
+ hidden = torch.randn(1, d)
402
+ tgt = torch.tensor([3, 17])
403
+
404
+ def loss():
405
+ x = torch.tanh(hidden @ w1.t() + b1)
406
+ logits = (x @ w2.t() + b2).view(1, n_resp, V)
407
+ return F.cross_entropy(logits.view(-1, V), tgt.view(-1))
408
+
409
+ l0 = loss().item()
410
+ for _ in range(30):
411
+ x = torch.tanh(hidden @ w1.t() + b1)
412
+ logits = (x @ w2.t() + b2).view(1, n_resp, V)
413
+ p = F.softmax(logits, dim=-1)
414
+ onehot = F.one_hot(tgt, V).float()
415
+ errs = (p - onehot).reshape(-1)
416
+ head.step(W, hidden[0], x[0], errs.detach())
417
+ l1 = loss().item()
418
+ stats = head.step_size_stats()
419
+ ok = l1 < l0 and stats['beta_mean'] > 0
420
+ print(f" {'OK' if ok else 'FAIL'} MLP head 30 步: loss "
421
+ f"{l0:.4f} → {l1:.4f}, β_mean={stats['beta_mean']:.4f}, "
422
+ f"bound 触发={stats['n_bound_triggers']}, decay={stats['n_decays']}")
423
+ return ok
424
+
425
+
426
+ def _self_test():
427
+ ok1 = _self_test_weight_flipping()
428
+ ok2 = _self_test_bound_stability()
429
+ ok3 = _self_test_mlp_head()
430
+ print(f"\n {int(ok1) + int(ok2) + int(ok3)}/3 passed")
431
+ return ok1 and ok2 and ok3
432
+
433
+
434
+ if __name__ == "__main__":
435
+ _self_test()
hibs_lnn/transition_model.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """transition_model.py — OaK 第③条的第一个缺失件。
2
+
3
+ ## 为什么需要它
4
+
5
+ OaK feature #3:
6
+ feature -> subproblem -> solution -> **transition model** -> planning
7
+
8
+ 我们已经有 feature / subproblem(6 域 / 60 细区间)/ solution(训练),
9
+ 但**没有转移模型也无法规划** —— 这是 leo 那张映射表里真正缺的东西。
10
+
11
+ ## 定义 (与 lm4 的数据结构对齐)
12
+
13
+ 能力状态 a_t = acc_matrix[t] (在 6 个域上的准确率画像, 6 维)
14
+ 动作 u_t = 第 t 步训练了哪个域 (one-hot, 6 维)
15
+ 转移 T : (a_t, u_t, ctx) -> Δa, a_{t+1} = a_t + Δa
16
+
17
+ 学 **Δa** 而不是 a_{t+1}, 因为这样 "copy 基线" 就是 Δa≡0,
18
+ 模型有没有学到东西一目了然。
19
+
20
+ ## 输入特征
21
+
22
+ x = [ a_t (6) | onehot(u_t) (6) | n_visits (6) | t/n_rounds (1) ] = 19 维
23
+
24
+ ## 基线 (必须打赢才有意义)
25
+
26
+ copy : Δa ≡ 0 实测 MAE 0.2986
27
+ constant : Δa ≡ mean(Δa|u) 实测 MAE 0.2723
28
+
29
+ 用 **ridge** 而不是 MLP: 样本量 ~1.8K, 参数少、可复现、无调参空间
30
+ (符合 leo 的"manual > framework"偏好)。
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import numpy as np
35
+
36
+
37
+ class TransitionModel:
38
+ """T(a_t, u_t, ctx) -> Δa 的线性(脊)转移模型。
39
+
40
+ 参数
41
+ ----
42
+ lam : 岭正则强度
43
+ """
44
+
45
+ def __init__(self, lam=1e-2):
46
+ self.lam = float(lam)
47
+ self.W = None # (fdim, n_dom)
48
+ self.n_dom = None
49
+ self.fitted = False
50
+
51
+ # ───────── 特征 ─────────
52
+ @staticmethod
53
+ def feat(a, u, n_visits, t_frac):
54
+ oh = np.zeros(len(a))
55
+ if 0 <= u < len(a):
56
+ oh[u] = 1.0
57
+ return np.concatenate([a, oh, n_visits, [t_frac]])
58
+
59
+ def build_xy(self, records):
60
+ """records: [(acc_matrix, dom_seq)] -> X, Y, groups"""
61
+ X, Y, G = [], [], []
62
+ for gi, (M, seq) in enumerate(records):
63
+ M = np.asarray(M, dtype=float)
64
+ n_dom = M.shape[1]
65
+ nvis = np.zeros(n_dom)
66
+ for t, u in enumerate(seq):
67
+ if t + 1 >= M.shape[0]:
68
+ break
69
+ if np.isnan(M[t]).any() or np.isnan(M[t + 1]).any():
70
+ nvis[u] += 1
71
+ continue
72
+ X.append(self.feat(M[t], int(u), nvis.copy(), t / max(1, len(seq))))
73
+ Y.append(M[t + 1] - M[t])
74
+ G.append(gi)
75
+ nvis[u] += 1
76
+ return (np.array(X), np.array(Y), np.array(G)) if X else (
77
+ np.zeros((0, 19)), np.zeros((0, 6)), np.zeros(0))
78
+
79
+ # ───────── 拟合 ─────────
80
+ def fit(self, records):
81
+ X, Y, _ = self.build_xy(records)
82
+ if len(X) == 0:
83
+ raise ValueError("没有可用样本")
84
+ self.n_dom = int(Y.shape[1])
85
+ # 只对 Δa 做岭回归 (含截距)
86
+ X1 = np.hstack([X, np.ones((len(X), 1))])
87
+ A = X1.T @ X1 + self.lam * np.eye(X1.shape[1])
88
+ self.W = np.linalg.solve(A, X1.T @ Y)
89
+ self.fitted = True
90
+ return self
91
+
92
+ def predict_delta(self, a, u, n_visits, t_frac):
93
+ x = np.append(self.feat(a, u, n_visits, t_frac), 1.0)
94
+ return x @ self.W
95
+
96
+ # ───────── 规划: 用 T 前向搜索选动作序列 ─────────
97
+ def plan(self, a0, horizon, beam=8, n_dom=None):
98
+ """beam search: 在 T 的预测下最大化末端平均准确率。
99
+
100
+ 这是 OaK 的 "planning" —— 用学到的转移模型做前向搜索,
101
+ 而不是靠外生给定的 schedule。
102
+ """
103
+ n_dom = int(n_dom if n_dom is not None else (self.n_dom or 0))
104
+ a0 = np.asarray(a0, dtype=float)
105
+ # beam 元素: (score, a, nvis, seq)
106
+ beams = [(float(np.nanmean(a0)), a0.copy(), np.zeros(n_dom), [])]
107
+ for t in range(horizon):
108
+ cand = []
109
+ for _, a, nvis, seq in beams:
110
+ for u in range(n_dom):
111
+ d = self.predict_delta(a, u, nvis, t / max(1, horizon))
112
+ a2 = a + d
113
+ nv2 = nvis.copy()
114
+ nv2[u] += 1
115
+ cand.append((float(np.nanmean(a2)), a2, nv2, seq + [u]))
116
+ cand.sort(key=lambda r: -r[0])
117
+ beams = cand[:beam]
118
+ return beams[0][3]
hibs_lnn/uncertainty_gate.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """uncertainty_gate.py — 覆盖度 + 不确定性门控, 与带不确定性的 rollout。
2
+
3
+ ## 为什么需要
4
+
5
+ 用户指出: 被动观测数据学出的 transition model **只知道数据覆盖过的 (s,a)**。
6
+ 若 policy 提出训练数据里没出现过的动作, `T(s,a_new)` 可能完全是幻觉。
7
+ 这正是我们实测到的: 转移模型对 `[2,2,2,2,2]` 预测 **0.9582**, 真实执行 **0.4357**。
8
+
9
+ 于是把「覆盖度门控」升级为两个条件:
10
+
11
+ allow(s,a) = [C(s,a) > tau_C] ∧ [U_T(s,a) < tau_U]
12
+
13
+ C(s,a) : 该 (状态, 动作) 有多少数据支持 (访问计数)
14
+ U_T : 转移预测的不确定性
15
+
16
+ ## U_T 怎么估 —— 用集成分歧, 不用拍脑袋
17
+
18
+ 单个模型无法报出自己有多不确定。标准做法是训练 **K 个 transition model**
19
+ (对数据做 bootstrap), 用它们的**预测分歧**作为 U_T:
20
+
21
+ U_T(s,a) = mean_i || T_i(s,a) − mean_j T_j(s,a) ||
22
+
23
+ 分歧大 = 该区域数据稀疏 / 模型没学到 → 不可信。
24
+ 这与 coverage 互补: coverage 看「来过没有」, U_T 看「学稳了没有」。
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import numpy as np
29
+
30
+
31
+ class TransitionEnsemble:
32
+ """K 个 bootstrap 转移模型的集成, 用预测分歧估计不确定性。
33
+
34
+ T_i: x_{t+1} = x_t + Δ_i, Δ_i = W_i @ φ(x_t, u)
35
+ """
36
+
37
+ def __init__(self, n_models=5, lam=1e-2, seed=0):
38
+ self.K = int(n_models)
39
+ self.lam = float(lam)
40
+ self.rng = np.random.default_rng(seed)
41
+ self.Ws = None
42
+ self.n_dom = None
43
+ self.dim_x = None
44
+ self.fdim = None
45
+
46
+ # ───────── 特征 ─────────
47
+ @staticmethod
48
+ def feat(x, u, n_dom, visits):
49
+ """特征 = [x, onehot(u)]。
50
+
51
+ ★ **不要加入常数列** (曾写成 concat([x, oh, [mean(visits)]]) 而
52
+ mean(visits)≡1 与截距完全共线): 实测条件数被推到 **1.24e16**(近乎奇异),
53
+ 后果是双向的 ——
54
+ ① 预测全错: Δ 估计 [35.6, 32.5, 10.3, 5.7], 真值应 ~0.3
55
+ ② 不确定性失效: U_T 对所有动作都 ≈3.27, 完全不区分噪声
56
+ 去掉后: Δ 恢复 [0.30, 0, 0, 0], U_T 动作0(噪声.02)=0.0018 vs
57
+ 动作2(噪声.55)=0.0399 -> **22× 区分**。
58
+ """
59
+ oh = np.zeros(n_dom)
60
+ if 0 <= u < n_dom:
61
+ oh[u] = 1.0
62
+ return np.concatenate([x, oh])
63
+
64
+ # ───────── 拟合 (bootstrap) ─────────
65
+ def fit(self, X, Y, n_dom, visits_dim=None):
66
+ """X: (N, dim_x+1) = [x_t, u]; Y: (N, dim_x) = **绝对** x_{t+1}。
67
+
68
+ ★ 接口约定: fit 与 predict **都用绝对下一状态**, 内部学 Δ 再加回 x。
69
+ 曾因约定不一致出过 bug —— 测试把绝对状态当 Δ 喂进来, 在质心 (x=0)
70
+ 处两者巧合相等, 掩盖了问题; 一旦远离质心 (R0=[4.95,4.55,1.36,1.22])
71
+ 就露出 `Δ=x+fx(u)` 的错误。现在接口自洽, 无法再误用。
72
+ """
73
+ self.dim_x = Y.shape[1]
74
+ self.n_dom = int(n_dom)
75
+ self.fdim = self.dim_x + self.n_dom # 无截距列
76
+ N = len(X)
77
+ D = Y - X[:, :self.dim_x] # 内部转成 Δ
78
+ self.Ws = []
79
+ for k in range(self.K):
80
+ idx = self.rng.integers(0, N, size=N) # bootstrap 重采样
81
+ F = np.array([self.feat(X[i, :self.dim_x], int(X[i, -1]),
82
+ self.n_dom, np.ones(self.n_dom))
83
+ for i in idx])
84
+ # ★ 不加截距列。onehot(u) 的各列之和恒等于 1, 与截距**完全共线**
85
+ # (Σ onehot = ones)。加了以后条件数 8.9e15, 解只在数据质心附近
86
+ # 碰巧正确, 远离即炸: 实测在区域中心 (R0=[4.95,4.55,1.36,1.22])
87
+ # 给出 Δ=[92.7, 96.4, 25.8, 23.7], 真值应为 [0.30, 0, 0, 0]。
88
+ # 去掉后设计矩阵满秩 (rank = dim_x + n_dom)。
89
+ A = F.T @ F + self.lam * np.eye(F.shape[1])
90
+ self.Ws.append(np.linalg.solve(A, F.T @ D[idx]))
91
+ return self
92
+
93
+ # ───────── 预测 + 分歧 ─────────
94
+ def predict(self, x, u, visits):
95
+ """返回 (**绝对**下一状态的集成均值, 分歧 U_T)。"""
96
+ if self.Ws is None:
97
+ return np.asarray(x, dtype=float).copy(), float("inf")
98
+ x = np.asarray(x, dtype=float)
99
+ f = self.feat(x, u, self.n_dom, visits) # 与 fit 一致, 无截距
100
+ preds = np.array([f @ W for W in self.Ws]) # (K, dim_x) = Δ
101
+ mean_d = preds.mean(0)
102
+ unc = float(np.mean(np.linalg.norm(preds - mean_d, axis=1)))
103
+ return x + mean_d, unc # 绝对下一状态
104
+
105
+ def rollout(self, x0, actions, visits, gate=None):
106
+ """多步 rollout: T^k(x0, a_{t:t+k})。返回 (轨迹, 逐步不确定性)。
107
+
108
+ gate 非 None 时逐步做可信度检查; 一旦某步不可信��**中止并标记**,
109
+ 而不是继续外推 (Q8: 无覆盖 → 预测不可靠)。
110
+ """
111
+ x = np.asarray(x0, dtype=float).copy()
112
+ traj, uncs, aborted_at = [x.copy()], [], None
113
+ for t, u in enumerate(actions):
114
+ if gate is not None:
115
+ ok, why = gate.allow(x, u, visits)
116
+ if not ok:
117
+ aborted_at = (t, why)
118
+ break
119
+ x_next, unc = self.predict(x, u, visits) # 已是绝对状态
120
+ if not np.isfinite(unc):
121
+ aborted_at = (t, "unc_inf")
122
+ break
123
+ x = x_next
124
+ traj.append(x.copy())
125
+ uncs.append(unc)
126
+ return np.array(traj), np.array(uncs), aborted_at
127
+
128
+
129
+ class TabularTransition:
130
+ """计数式转移模型 —— 离散状态空间上的**可表达**动力学。
131
+
132
+ ## 为什么必须加它 (实测动机)
133
+
134
+ `TransitionEnsemble` 是**线性 ridge 回归** (`[state, onehot(action)] -> next`),
135
+ 对**离散、条件性、非线性**的动力学表达能力不足。实测在 KeyDoor 这类
136
+ 链式任务上: 从区域中心出发, **四个动作预测到同一个下一状态** ->
137
+ 转移图退化成几乎无边 -> `OptionManager` 发现 0 个 option
138
+ (即使任务里有明显的"拿钥匙→开门"技能链)。
139
+
140
+ ★ 结论: option 发现失败**不是 option 逻辑的问题, 而是它依赖的世界模型
141
+ 表达不了结构化动力学**。这里是那个缺口的补丁。
142
+
143
+ `predict(x, a)` 与 `TransitionEnsemble` **接口一致**, 可直接替换:
144
+ 返回 (最可能的下一状态, 不确定性)
145
+ 不确定性 = 1 − max_a' P(s'|s,a) —— 即"这个转移有多确定"。
146
+ """
147
+ def __init__(self, lam=1.0):
148
+ self.counts = {} # (s_key, a) -> {s'_key: n}
149
+ self.state_map = {} # s_key -> 原状态向量
150
+ self.lam = float(lam)
151
+ self.dim_x = None
152
+ self.n_dom = None
153
+
154
+ @staticmethod
155
+ def _key(x):
156
+ return tuple(np.round(np.asarray(x, dtype=float), 6).tolist())
157
+
158
+ def fit(self, X, Y, n_dom, visits_dim=None):
159
+ X = np.asarray(X, dtype=float); Y = np.asarray(Y, dtype=float)
160
+ self.dim_x = Y.shape[1]; self.n_dom = int(n_dom)
161
+ self.counts, self.state_map = {}, {}
162
+ for i in range(len(X)):
163
+ x, u, y = X[i, :self.dim_x], int(X[i, -1]), Y[i]
164
+ sk, yk = self._key(x), self._key(y)
165
+ self.state_map[sk] = x
166
+ self.counts.setdefault((sk, u), {})
167
+ self.counts[(sk, u)][yk] = self.counts[(sk, u)].get(yk, 0) + 1
168
+ return self
169
+
170
+ def predict(self, x, u, visits=None):
171
+ """返回 (最可能下一状态向量, 不确定性 1−P_max)。未见过的 (s,a) -> 原状态 + unc=inf。"""
172
+ sk = self._key(x)
173
+ d = self.counts.get((sk, int(u)))
174
+ if not d:
175
+ return np.asarray(x, dtype=float).copy(), float("inf")
176
+ tot = sum(d.values())
177
+ yk, c = max(d.items(), key=lambda kv: kv[1])
178
+ return np.array(yk, dtype=float), float(1.0 - c / tot)
179
+
180
+ def rollout(self, x0, actions, visits=None, gate=None):
181
+ x = np.asarray(x0, dtype=float).copy()
182
+ traj, uncs, aborted = [x.copy()], [], None
183
+ for t, u in enumerate(actions):
184
+ if gate is not None:
185
+ ok, why = gate.allow(x, u, visits if visits is not None else np.ones(1))
186
+ if not ok:
187
+ aborted = (t, why); break
188
+ xn, unc = self.predict(x, u, visits)
189
+ if not np.isfinite(unc):
190
+ aborted = (t, "unc_inf"); break
191
+ x = xn; traj.append(x.copy()); uncs.append(unc)
192
+ return np.array(traj), np.array(uncs), aborted
193
+
194
+
195
+ class UncertaintyGate:
196
+ """allow(s,a) = [C(s,a) > tau_C] ∧ [U_T(s,a) < tau_U]
197
+
198
+ 参数
199
+ ----
200
+ tau_C : 覆盖度阈值 (访问计数的分位/绝对阈值)
201
+ tau_U : 不确定性阈值 (集成分歧)
202
+ """
203
+
204
+ def __init__(self, tau_C=1.0, tau_U=None, adaptive=True):
205
+ self.tau_C = float(tau_C)
206
+ self.tau_U = tau_U
207
+ self.adaptive = adaptive
208
+ self.unc_hist = []
209
+
210
+ def calibrate(self, uncs):
211
+ """用一批**在训练数据上**的集成分歧定阈值 (取 90 分位)。
212
+
213
+ 自适应是必要的: 分歧的量纲随任务变化, 硬编码阈值不可移植。
214
+ """
215
+ u = np.asarray(uncs, dtype=float)
216
+ u = u[np.isfinite(u)]
217
+ if u.size:
218
+ self.tau_U = float(np.quantile(u, 0.90))
219
+ return self.tau_U
220
+
221
+ def allow(self, x, u, visits):
222
+ """返回 (是否允许, 原因)。"""
223
+ c = float(visits[u]) if u < len(visits) else 0.0
224
+ if c <= self.tau_C:
225
+ return False, "low_coverage"
226
+ return True, "ok" # 不确定性检查在 rollout 内逐步做
227
+
228
+ def allow_with_unc(self, x, u, visits, unc):
229
+ """含不确定性判定的完整版。"""
230
+ ok, why = self.allow(x, u, visits)
231
+ if not ok:
232
+ return False, why
233
+ if self.tau_U is not None and np.isfinite(unc) and unc > self.tau_U:
234
+ return False, "high_uncertainty"
235
+ return True, "ok"
236
+
237
+ def stats(self):
238
+ return {"tau_C": self.tau_C,
239
+ "tau_U": None if self.tau_U is None else round(self.tau_U, 6)}
hibs_lnn/value_proposer.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """value_proposer.py — 价值函数提议器 (从 LMT-twister V35.19 移植, 适配连续物理区间)。
2
+
3
+ ## 来源
4
+
5
+ LMT-twister 家族已把这条线验证到生产级 (skill: lmt-twister-experiment):
6
+ V35.19 价值函数提议器 (用户理论): 简约性 + 自洽性 + 覆盖增量
7
+ V35.20 价值函数表示迁移最优 (0.0869 > 外部课程 0.0742)
8
+ V35.21 三 seed 确认 (0.0850±0.002), S5 内直接训练 scratch +2.05pp
9
+ V35.22 价值函数 = 稳定器 (方差缩 3 倍)
10
+ LM1 生产级: 提议器每步自主生成数据
11
+
12
+ 原式 (V35.19):
13
+ V(p) = λ_sim·sim(p) + λ_con·con(p) + λ_cov/(1+freq(p))
14
+ sim(p) = 1 - max_{q∈已学} struct_sim(p,q) # 简约性: 不可归约度
15
+ con(p) = 1 - 矛盾度 # 自洽性
16
+ cov(p) = 1/(1+freq(p)) # 覆盖增量
17
+
18
+ ## 本移植
19
+
20
+ lm1 的"结构 p"是离散排列; lm4/lm5 的"结构"是**连续的物理区间**,
21
+ 所以相似度换成描述子空间里的距离, 并新增一项**真实反馈** (V35 里由
22
+ CausalProposer.feedback_value 承担)。
23
+
24
+ V(p) = λ_sim·sim(p) + λ_con·con(p) + λ_cov·cov(p) + λ_fb·fb(p)
25
+
26
+ sim(p) = 1 - max_{q∈已学} exp(-‖z_p - z_q‖ / σ) # 物理上离已学区间多远
27
+ con(p) = 自洽性 = 该区间与已发现因果结构的吻合度 (由调用方注入)
28
+ cov(p) = 1/(1+freq(p)) # 训练越少越值得学
29
+ fb(p) = 1 - acc(p) # 真实反馈: 越不准越值得学
30
+
31
+ ## 关键坑 (V35.19 实测, 必须遵守)
32
+
33
+ 1. **候选池必须远大于提议数**, 否则价值函数被抹平 (v1: 池 18 个被全覆盖 ->
34
+ 提议顺序无关 -> λ 扫描零效果)。本实现默认要求 len(pool) >> k·轮数。
35
+ 2. **λ_cov 倒 U 型**, 平衡点 ≈ 1:1:1。默认 (1,1,1,1)。
36
+ 3. **覆盖项必须真的有频率差异**, 否则退化成随机 (v2 教训: 高频重复 ≠ 均匀覆盖)。
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import numpy as np
41
+
42
+
43
+ class RegimeProposer:
44
+ """按价值函数从候选区间池里采样"下一个学哪个物理区间"。
45
+
46
+ 参数
47
+ ----
48
+ desc : (N, D) 每个候选区间的物理描述子 (如 [log_dens, logL, absmaglat]).
49
+ 内部做 z-score, 保证各维等权。
50
+ groups : (N,) 每个候选区间映射到哪个**评估域** (用于汇总/报告, 不参与打分)。
51
+ lam : (λ_sim, λ_con, λ_cov, λ_fb)
52
+ sigma : 描述子空间里"多远算不可归约"的尺度 (z-score 单位, 默认 0.5)
53
+ tau : softmax 温度, 越小越贪婪
54
+ k : 每次提议几个
55
+ seed : 采样随机种子
56
+ """
57
+
58
+ def __init__(self, desc, groups=None, lam=(1.0, 1.0, 1.0, 1.0),
59
+ sigma=None, tau=0.5, k=2, seed=0):
60
+ desc = np.asarray(desc, dtype=np.float64)
61
+ self.n = len(desc)
62
+ mu = desc.mean(0)
63
+ sd = desc.std(0)
64
+ sd[sd < 1e-9] = 1.0
65
+ self.z = (desc - mu) / sd
66
+ # ★ sigma 默认由数据决定, 不能用固定 0.5。
67
+ # 低维描述子 (1-3 维) 下候选间距离量级很小, 固定 sigma=0.5 会让
68
+ # exp(-d/sigma) ≈ 0 对所有候选成立 -> sim(p) ≡ 1.0 -> 简约性项死亡
69
+ # (实测 signature: value 与 value-nofb 提议序列逐位相同)。
70
+ # 取"最近邻距离的中位数"当尺度, 保证核在真实分布上有区分度。
71
+ if sigma is None:
72
+ d = np.linalg.norm(self.z[:, None, :] - self.z[None, :, :], axis=-1)
73
+ np.fill_diagonal(d, np.inf)
74
+ sigma = float(np.median(d.min(axis=1)))
75
+ sigma = max(sigma, 1e-3)
76
+ self.sigma = float(sigma)
77
+ self.groups = (np.asarray(groups) if groups is not None
78
+ else np.zeros(self.n, dtype=int))
79
+ self.lam = tuple(float(x) for x in lam)
80
+ self.tau = float(tau)
81
+ self.k = int(k)
82
+ self.rng = np.random.default_rng(seed)
83
+ # 状态
84
+ self._con = None # 自洽性分数 (可注入)
85
+ self.freq = np.zeros(self.n, dtype=np.float64) # 被提议次数
86
+ self.learned = [] # 已学区间索引
87
+ self.acc = np.full(self.n, np.nan) # 各区间的实测准确率
88
+ self.trace = [] # 每轮提议记录
89
+
90
+ # ---------------- 三项 + 反馈 ----------------
91
+ def sim(self, i):
92
+ """简约性: 1 - 与已学区间的最大相似度 (物理上离得越远越值得学)。"""
93
+ if not self.learned:
94
+ return 1.0
95
+ d = np.linalg.norm(self.z[self.learned] - self.z[i], axis=1)
96
+ return float(1.0 - np.exp(-d.min() / self.sigma))
97
+
98
+ def cov(self, i):
99
+ """覆盖增量: 越少被训练越值得学。"""
100
+ return 1.0 / (1.0 + self.freq[i])
101
+
102
+ def con(self, i):
103
+ """自洽性 (V35.19 三项之一) —— 由调用方 set_consistency() 注入。
104
+
105
+ **没注入时返回恒定 1.0, 该维对排序零贡献** ��— 这是本轮踩过的坑:
106
+ 忘了注入 -> 自洽性项死亡 -> 价值函数退化成覆盖均匀化采样器。
107
+ 诊断: stats() 里的 con_std; 若为 0 说明这一项是死的。
108
+ """
109
+ return float(self._con[i]) if self._con is not None else 1.0
110
+
111
+ def fb(self, i):
112
+ """真实反馈: 越不准越值得学 (持续学习那一环)。"""
113
+ a = self.acc[i]
114
+ if not np.isfinite(a):
115
+ return 1.0 # 没测过 = 最有价值
116
+ return float(1.0 - a)
117
+
118
+ def value(self, i):
119
+ ls, lc, lv, lf = self.lam
120
+ return (ls * self.sim(i) + lc * self.con(i)
121
+ + lv * self.cov(i) + lf * self.fb(i))
122
+
123
+ # ---------------- 外部注入 ----------------
124
+ def set_consistency(self, con):
125
+ """注入自洽性分数 (长度 N)。对 lm4: 每个区间与已发现因果边的吻合度。"""
126
+ self._con = np.asarray(con, dtype=np.float64)
127
+
128
+ def observe(self, idx, acc, learned=True):
129
+ """记录一轮的结果: 该区间的实测准确率 (+ 标记为已学)。"""
130
+ self.acc[idx] = float(acc)
131
+ if learned and idx not in self.learned:
132
+ self.learned.append(int(idx))
133
+
134
+ # ---------------- 提议 ----------------
135
+ def propose(self, k=None):
136
+ """按 V 的 softmax 采样 k 个不重复候选 (采样而非 argmax: 保留探索)。"""
137
+ k = k or self.k
138
+ v = np.array([self.value(i) for i in range(self.n)])
139
+ # 数值稳定 + 温度
140
+ v = v - v.max()
141
+ p = np.exp(v / max(1e-9, self.tau))
142
+ p = p / p.sum()
143
+ kk = min(k, self.n)
144
+ idx = self.rng.choice(self.n, size=kk, replace=False, p=p)
145
+ for i in idx:
146
+ self.freq[i] += 1
147
+ self.trace.append(list(map(int, idx)))
148
+ return list(map(int, idx))
149
+
150
+ def pick_random(self, k=None):
151
+ """随机对照臂。"""
152
+ k = k or self.k
153
+ idx = self.rng.choice(self.n, size=min(k, self.n), replace=False)
154
+ for i in idx:
155
+ self.freq[i] += 1
156
+ self.trace.append(list(map(int, idx)))
157
+ return list(map(int, idx))
158
+
159
+ # ---------------- 诊断 ----------------
160
+ def stats(self):
161
+ v = np.array([self.value(i) for i in range(self.n)])
162
+ # ★ 各项的**区分度**诊断: 标准差为 0 = 该项是死的 (对排序零贡献)
163
+ _sim = np.array([self.sim(i) for i in range(self.n)])
164
+ _con = np.array([self.con(i) for i in range(self.n)])
165
+ _cov = np.array([self.cov(i) for i in range(self.n)])
166
+ _fb = np.array([self.fb(i) for i in range(self.n)])
167
+ return {
168
+ "n_candidates": int(self.n),
169
+ "n_learned": len(self.learned),
170
+ "n_proposed_total": int(self.freq.sum()),
171
+ "freq_nonzero": int((self.freq > 0).sum()),
172
+ "value_mean": float(v.mean()),
173
+ "value_std": float(v.std()),
174
+ "value_cv": float(v.std() / max(1e-9, abs(v.mean()))),
175
+ "covered_frac": float((self.freq > 0).mean()),
176
+ "sigma": float(self.sigma),
177
+ # 各项区分度: 0 = 该项死了 (本轮踩过的坑)
178
+ "term_std": {"sim": float(_sim.std()), "con": float(_con.std()),
179
+ "cov": float(_cov.std()), "fb": float(_fb.std())},
180
+ }
manifests/delta_compile_report.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Delta Compile Report
2
+
3
+ - generated_at: `2026-09-06T05:17:16+00:00`
4
+ - stale_pages: `0`
5
+ - new_raw_sources: `1`
6
+ - drafts_written: `0`
7
+
8
+ > This report suggests recompilation work. It does not auto-overwrite wiki content.
9
+
10
+ ## Stale Pages
11
+
12
+ - none
13
+
14
+ ## New Raw Sources
15
+
16
+ - `docs/wiki/log.md` -> suggested page `docs/wiki/log.md`
17
+ -
18
+
19
+ ## Draft Files
20
+
21
+ - none
manifests/knowledge_graph.json ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 2,
3
+ "wiki_root": "/Users/apple/Downloads/headless/docs/wiki",
4
+ "nodes": [
5
+ {
6
+ "id": "README.md",
7
+ "type": "meta",
8
+ "label": "README.md",
9
+ "tags": "",
10
+ "stage": "",
11
+ "audience": "",
12
+ "source_file": "docs/wiki/README.md"
13
+ },
14
+ {
15
+ "id": "SCHEMA.md",
16
+ "type": "meta",
17
+ "label": "SCHEMA.md",
18
+ "tags": "",
19
+ "stage": "",
20
+ "audience": "",
21
+ "source_file": "docs/wiki/SCHEMA.md"
22
+ },
23
+ {
24
+ "id": "causal_lm1_experiment.md",
25
+ "type": "meta",
26
+ "label": "LM1 因果持续学习对照实验",
27
+ "tags": "",
28
+ "stage": "draft",
29
+ "audience": "public",
30
+ "source_file": "docs/wiki/causal_lm1_experiment.md"
31
+ },
32
+ {
33
+ "id": "causal_stack.md",
34
+ "type": "meta",
35
+ "label": "causal 因果技术栈落地",
36
+ "tags": "",
37
+ "stage": "draft",
38
+ "audience": "public",
39
+ "source_file": "docs/wiki/causal_stack.md"
40
+ },
41
+ {
42
+ "id": "current-status.md",
43
+ "type": "meta",
44
+ "label": "current-status",
45
+ "tags": "",
46
+ "stage": "draft",
47
+ "audience": "public",
48
+ "source_file": "docs/wiki/current-status.md"
49
+ },
50
+ {
51
+ "id": "github-and-raw-strategy.md",
52
+ "type": "meta",
53
+ "label": "github-and-raw-strategy",
54
+ "tags": "",
55
+ "stage": "draft",
56
+ "audience": "public",
57
+ "source_file": "docs/wiki/github-and-raw-strategy.md"
58
+ },
59
+ {
60
+ "id": "index.md",
61
+ "type": "meta",
62
+ "label": "index.md",
63
+ "tags": "",
64
+ "stage": "",
65
+ "audience": "",
66
+ "source_file": "docs/wiki/index.md"
67
+ },
68
+ {
69
+ "id": "log.md",
70
+ "type": "meta",
71
+ "label": "log.md",
72
+ "tags": "",
73
+ "stage": "",
74
+ "audience": "",
75
+ "source_file": "docs/wiki/log.md"
76
+ },
77
+ {
78
+ "id": "project-overview.md",
79
+ "type": "meta",
80
+ "label": "project-overview",
81
+ "tags": "",
82
+ "stage": "draft",
83
+ "audience": "public",
84
+ "source_file": "docs/wiki/project-overview.md"
85
+ },
86
+ {
87
+ "id": "sources-and-data.md",
88
+ "type": "meta",
89
+ "label": "sources-and-data",
90
+ "tags": "",
91
+ "stage": "draft",
92
+ "audience": "public",
93
+ "source_file": "docs/wiki/sources-and-data.md"
94
+ }
95
+ ],
96
+ "edges": [
97
+ {
98
+ "from": "index.md",
99
+ "to": "log.md",
100
+ "type": "references"
101
+ },
102
+ {
103
+ "from": "index.md",
104
+ "to": "causal_stack.md",
105
+ "type": "references"
106
+ },
107
+ {
108
+ "from": "index.md",
109
+ "to": "causal_lm1_experiment.md",
110
+ "type": "references"
111
+ },
112
+ {
113
+ "from": "index.md",
114
+ "to": "current-status.md",
115
+ "type": "references"
116
+ },
117
+ {
118
+ "from": "index.md",
119
+ "to": "github-and-raw-strategy.md",
120
+ "type": "references"
121
+ },
122
+ {
123
+ "from": "index.md",
124
+ "to": "project-overview.md",
125
+ "type": "references"
126
+ },
127
+ {
128
+ "from": "index.md",
129
+ "to": "sources-and-data.md",
130
+ "type": "references"
131
+ }
132
+ ],
133
+ "stats": {
134
+ "node_count": 10,
135
+ "edge_count": 7,
136
+ "node_types": {
137
+ "meta": 10
138
+ },
139
+ "edge_types": {
140
+ "references": 7
141
+ }
142
+ }
143
+ }
manifests/raw_sources.csv ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ source_id,company,vendor,kind,filename,raw_rel_path,status,compiled_into,filename,content_hash,size_bytes,ingested_at,compiled_at,last_validated_at,confidence,supersedes,lifecycle_stage,crystallized_into,owner,audience,stage,tags,notes,schema_version
2
+ 1,headless,,experiment-log,log.md,docs/wiki/log.md,new,,log.md,d8a4a23a1a848bb5,6572,2026-09-06,2026-09-06,high,,,元木,reader,draft,continuous-learning,Primary experiment log,2
manifests/raw_sources.meta.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 2,
3
+ "columns": [
4
+ "source_id",
5
+ "company",
6
+ "vendor",
7
+ "kind",
8
+ "filename",
9
+ "raw_rel_path",
10
+ "status",
11
+ "compiled_into",
12
+ "filename",
13
+ "content_hash",
14
+ "size_bytes",
15
+ "ingested_at",
16
+ "compiled_at",
17
+ "last_validated_at",
18
+ "confidence",
19
+ "supersedes",
20
+ "lifecycle_stage",
21
+ "crystallized_into",
22
+ "owner",
23
+ "audience",
24
+ "stage",
25
+ "tags",
26
+ "notes",
27
+ "schema_version"
28
+ ],
29
+ "allowed_status": [
30
+ "new",
31
+ "compiled",
32
+ "archived"
33
+ ]
34
+ }
manifests/stale_report.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stale Report
2
+
3
+ - generated_at: `2026-09-06T05:10:45+00:00`
4
+ - raw_root: `not configured`
5
+ - fresh_pages: `0`
6
+ - stale_pages: `0`
7
+ - missing_hash: `6`
8
+ - unresolved_sources: `0`
9
+ - archived_refs: `0`
10
+ - manifest_new: `1`
11
+ - session_exempt: `0`
12
+
13
+ ## Fresh Pages
14
+
15
+ - none
16
+
17
+ ## Pages Needing Recompile (stale)
18
+
19
+ - none
20
+
21
+ ## Pages Missing source_hash
22
+
23
+ - `docs/wiki/causal_lm1_experiment.md`
24
+ - `docs/wiki/causal_stack.md`
25
+ - `docs/wiki/current-status.md`
26
+ - `docs/wiki/github-and-raw-strategy.md`
27
+ - `docs/wiki/project-overview.md`
28
+ - `docs/wiki/sources-and-data.md`
29
+
30
+ ## Pages With Unresolved source
31
+
32
+ - none
33
+
34
+ ## Pages Pointing At Archived Sources
35
+
36
+ - none
37
+
38
+ ## Raw Files Still Waiting For First Compile
39
+
40
+ - `docs/wiki/log.md`
scripts/bootstrap_knowledge_system.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bootstrap a compile-first knowledge system into a target repo.
2
+
3
+ The actual file contents live as real files under
4
+ `skills/knowledge-system-bootstrap/templates/`. This script copies them
5
+ into the target with sentinel-string substitution. That keeps every
6
+ generated script lintable, testable, and reviewable on its own.
7
+
8
+ Substitution sentinels (plain string.replace, no Template machinery so
9
+ templates can contain literal `$`):
10
+ __PROJECT_NAME__ — human-readable project name passed on the CLI
11
+ __RAW_ROOT_NAME__ — folder name for the local raw root
12
+ __TODAY__ — today's date in YYYY-MM-DD
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import re
18
+ import shutil
19
+ from datetime import date, datetime
20
+ from pathlib import Path
21
+
22
+ __version__ = "2.0.0" # bumped for v2 scripts (wiki_lint, supersede_check, crystallize, hybrid_search, graph_export, intake_filter) merged under SKILL_ROOT/scripts/
23
+
24
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
25
+ TEMPLATES = SKILL_ROOT / "templates"
26
+
27
+
28
+ # (template-relative path, target-relative path)
29
+ TEMPLATE_TO_TARGET: list[tuple[str, str]] = [
30
+ ("configs/AGENTS.md", "AGENTS.md"),
31
+ ("configs/CLAUDE.md", "CLAUDE.md"),
32
+ ("configs/.cursorrules", ".cursorrules"),
33
+ ("configs/.windsurfrules", ".windsurfrules"),
34
+ ("configs/.gitignore", ".gitignore"),
35
+ ("wiki/README.md", "docs/wiki/README.md"),
36
+ ("wiki/SCHEMA.md", "docs/wiki/SCHEMA.md"),
37
+ ("wiki/index.md", "docs/wiki/index.md"),
38
+ ("wiki/log.md", "docs/wiki/log.md"),
39
+ ("wiki/project-overview.md", "docs/wiki/project-overview.md"),
40
+ ("wiki/current-status.md", "docs/wiki/current-status.md"),
41
+ ("wiki/sources-and-data.md", "docs/wiki/sources-and-data.md"),
42
+ ("wiki/github-and-raw-strategy.md", "docs/wiki/github-and-raw-strategy.md"),
43
+ ("wiki/runtime-profile.md", "docs/wiki/runtime-profile.md"),
44
+ ("manifests/README.md", "manifests/README.md"),
45
+ ("manifests/raw_sources.csv", "manifests/raw_sources.csv"),
46
+ ("manifests/raw_sources.meta.json", "manifests/raw_sources.meta.json"),
47
+ ("scripts/wiki_check.py", "scripts/wiki_check.py"),
48
+ ("scripts/raw_manifest_check.py", "scripts/raw_manifest_check.py"),
49
+ ("scripts/ingest_raw.py", "scripts/ingest_raw.py"),
50
+ ("scripts/untracked_raw_check.py", "scripts/untracked_raw_check.py"),
51
+ ("scripts/provenance_check.py", "scripts/provenance_check.py"),
52
+ ("scripts/stale_report.py", "scripts/stale_report.py"),
53
+ ("scripts/delta_compile.py", "scripts/delta_compile.py"),
54
+ ("scripts/version_check.py", "scripts/version_check.py"),
55
+ ("scripts/wiki_size_report.py", "scripts/wiki_size_report.py"),
56
+ ("scripts/init_raw_root.py", "scripts/init_raw_root.py"),
57
+ ("scripts/export_memory_repo.py", "scripts/export_memory_repo.py"),
58
+ ("scripts/upgrade.sh", "scripts/upgrade.sh"),
59
+ # v2.0.0 — new scripts merged under scripts/, no longer in templates/scripts/
60
+ ("scripts/wiki_lint.py", "scripts/wiki_lint.py"),
61
+ ("scripts/supersede_check.py", "scripts/supersede_check.py"),
62
+ ("scripts/crystallize.py", "scripts/crystallize.py"),
63
+ ("scripts/hybrid_search.py", "scripts/hybrid_search.py"),
64
+ ("scripts/graph_export.py", "scripts/graph_export.py"),
65
+ ("scripts/intake_filter.py", "scripts/intake_filter.py"),
66
+ ("claude-commands/wiki-check.md", ".claude/commands/wiki-check.md"),
67
+ ("claude-commands/wiki-upgrade.md", ".claude/commands/wiki-upgrade.md"),
68
+ ("claude-commands/wiki-status.md", ".claude/commands/wiki-status.md"),
69
+ ("github/workflows/wiki-lint.yml", ".github/workflows/wiki-lint.yml"),
70
+ ]
71
+
72
+
73
+ def slugify(value: str) -> str:
74
+ value = value.strip().lower()
75
+ value = re.sub(r"[^a-z0-9]+", "_", value)
76
+ return value.strip("_") or "project"
77
+
78
+
79
+ def render(body: str, vars: dict[str, str]) -> str:
80
+ out = body
81
+ for sentinel, value in vars.items():
82
+ out = out.replace(sentinel, value)
83
+ unresolved = re.findall(r"__[A-Z][A-Z0-9_]*__", out)
84
+ expected = {"__main__", "__init__", "__name__", "__file__"}
85
+ real = [s for s in unresolved if s not in expected]
86
+ if real:
87
+ raise RuntimeError(f"unresolved sentinels: {set(real)}")
88
+ return out
89
+
90
+
91
+ def write(path: Path, content: str) -> None:
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ path.write_text(content, encoding="utf-8")
94
+
95
+
96
+ def backup(path: Path) -> Path:
97
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
98
+ dest = path.with_suffix(path.suffix + f".bak.{stamp}")
99
+ shutil.copy2(path, dest)
100
+ return dest
101
+
102
+
103
+ def main() -> int:
104
+ parser = argparse.ArgumentParser(description="Bootstrap a compile-first knowledge system into another repo.")
105
+ parser.add_argument("target_dir", help="Target repository root")
106
+ parser.add_argument("project_name", help="Human-readable project name")
107
+ parser.add_argument("--force", action="store_true", help="Overwrite existing files (a .bak.<timestamp> copy is kept unless --no-backup)")
108
+ parser.add_argument("--no-backup", action="store_true", help="When overwriting, skip the .bak file (only effective with --force)")
109
+ parser.add_argument("--dry-run", action="store_true", help="Show what would happen without writing anything")
110
+ parser.add_argument("--raw-root-name", help="Folder name for the local raw root")
111
+ args = parser.parse_args()
112
+
113
+ target = Path(args.target_dir).expanduser().resolve()
114
+ project_name = args.project_name.strip()
115
+ slug = slugify(project_name)
116
+ raw_root_name = args.raw_root_name or f"{slug}_raw"
117
+ today = date.today().isoformat()
118
+
119
+ vars = {
120
+ "__PROJECT_NAME__": project_name,
121
+ "__RAW_ROOT_NAME__": raw_root_name,
122
+ "__TODAY__": today,
123
+ }
124
+
125
+ created: list[Path] = []
126
+ overwritten: list[tuple[Path, Path | None]] = []
127
+ skipped: list[Path] = []
128
+ unchanged: list[Path] = []
129
+
130
+ for tmpl_rel, target_rel in TEMPLATE_TO_TARGET:
131
+ # v2.0.0: scripts/ under SKILL_ROOT/scripts/ (not templates/scripts/)
132
+ if tmpl_rel.startswith("scripts/"):
133
+ src = SKILL_ROOT / tmpl_rel
134
+ else:
135
+ src = TEMPLATES / tmpl_rel
136
+ if not src.exists():
137
+ print(f"WARNING: template missing: {tmpl_rel}")
138
+ continue
139
+ body = src.read_text(encoding="utf-8")
140
+ try:
141
+ content = render(body, vars)
142
+ except RuntimeError as exc:
143
+ raise SystemExit(f"template {tmpl_rel}: {exc}")
144
+
145
+ dest = target / target_rel
146
+ if not dest.exists():
147
+ if not args.dry_run:
148
+ write(dest, content)
149
+ created.append(dest)
150
+ elif dest.read_text(encoding="utf-8") == content:
151
+ unchanged.append(dest)
152
+ elif args.force:
153
+ bak_path: Path | None = None
154
+ if not args.dry_run:
155
+ if not args.no_backup:
156
+ bak_path = backup(dest)
157
+ write(dest, content)
158
+ overwritten.append((dest, bak_path))
159
+ else:
160
+ skipped.append(dest)
161
+
162
+ prefix = "[DRY RUN] " if args.dry_run else ""
163
+ print(f"{prefix}Bootstrapped knowledge system for: {project_name}")
164
+ print(f"Target repo: {target}")
165
+ print(f"Default raw root name: {raw_root_name}")
166
+
167
+ if created:
168
+ print(f"\nCreated ({len(created)}):")
169
+ for p in created:
170
+ print(f" + {p.relative_to(target)}")
171
+ if overwritten:
172
+ print(f"\nOverwritten ({len(overwritten)}):")
173
+ for p, bak in overwritten:
174
+ suffix = f" (backup: {bak.name})" if bak else " (no backup)"
175
+ print(f" ! {p.relative_to(target)}{suffix}")
176
+ if unchanged:
177
+ print(f"\nUnchanged ({len(unchanged)})")
178
+ if skipped:
179
+ print(f"\nSkipped — already exists, content differs ({len(skipped)}):")
180
+ print("Use --force to overwrite.")
181
+ for p in skipped:
182
+ print(f" ~ {p.relative_to(target)}")
183
+
184
+ if not args.dry_run and (created or overwritten):
185
+ print("\nNext steps:")
186
+ print(f"1. cd {target}")
187
+ print("2. python3 scripts/init_raw_root.py")
188
+ print("3. python3 scripts/wiki_check.py")
189
+ print("4. python3 scripts/raw_manifest_check.py")
190
+
191
+ return 0
192
+
193
+
194
+ if __name__ == "__main__":
195
+ raise SystemExit(main())