CIE-Unified

git clone 

commit
7a0c5c0
parent
dc722fa
author
codex@macbookpro
date
2026-04-01 12:15:44 +0800 CST
feat: phase3 sedimentation dual-write on Branch B kernel
6 files changed,  +402, -16
M MERGE_PLAN.md
+21, -13
 1@@ -10,8 +10,11 @@
 2 > 并通过 quick gate、Phase 2.3 gate、broader regression gate、formal validation smoke。
 3 > `feat/phase2.4-kernel-math-cleanup` 已完成 Phase 2.4(weighted-degree normalization + net-flow circulation),
 4 > 并通过 quick gate、Phase 2.4 gate、broader regression gate、formal validation smoke。
 5-> **Phase 3 尚未开始**;只有在 Phase 2.4 gates 全通过后,才允许进入 Phase 3,
 6-> 且仍保持 **dual-write first, replacement later**。
 7+> `feat/phase3-sedimentation-dualwrite` 已完成 Phase 3(SedimentationProfile dual-write observational layer),
 8+> 保持 **Branch B 的 `experience_hits` / `experience_regions` / `skill_belt_candidates` / `ability_cores` 为运行时真相源**,
 9+> 并通过 quick gate、Phase 3 gate、broader regression gate、formal validation smoke。
10+> **Phase 4+ 尚未开始**;Phase 3 仍保持 **dual-write first, replacement later**,
11+> 尚未替换 `experience_hits`。
12 
13 ---
14 
15@@ -159,7 +162,7 @@ reset_session()
16    `python3 -m pytest tests/test_phase23_validation_sanity.py -q`
17    `python3 -m pytest tests/test_smoke.py tests/test_dynamics.py tests/test_exactly_once.py tests/test_kernel_sanity.py tests/test_comprehensive.py tests/test_phase23_validation_sanity.py -q`
18    `python3 tests/formal_validation.py`
19-6. Phase 2.3 完成后,Phase 3 才允许开始;当前 **Phase 3 仍未开始**
20+6. Phase 2.3 完成后,Phase 3 才允许开始;该前置条件已在 Phase 2.4 完成后满足
21 
22 ### Phase 2.4: Kernel Math Cleanup(已完成)
23 
24@@ -175,16 +178,21 @@ reset_session()
25    `python3 -m pytest tests/test_phase24_kernel_math.py -q`
26    `python3 -m pytest tests/test_smoke.py tests/test_dynamics.py tests/test_exactly_once.py tests/test_kernel_sanity.py tests/test_phase23_validation_sanity.py tests/test_comprehensive.py tests/test_phase24_kernel_math.py -q`
27    `python3 tests/formal_validation.py`
28-5. 只有在 Phase 2.4 gates 全通过后,Phase 3 才允许开始;当前 **Phase 3 仍未开始**
29-
30-### Phase 3: 沉积 Profile 并行观测(未开始,Day 2)
31-
32-1. 从 Branch A 移植 `SedimentationProfile` dataclass 到 `cie/state.py`
33-2. 保留 Branch B 的 `experience_hits` 作为已验证行为基线;先把 Profile 做成 dual-write / parallel observation
34-3. 在 snapshot_state 中输出 Profile 信息与对照指标,比较 Profile 与 `experience_hits` 的行为对齐
35-4. 跑测试确认沉积路径功能不退化;若不对齐,不替换 `experience_hits`
36-5. 后续如需真正替换 `experience_hits`,必须在额外 gate 中证明行为保持一致后再做,不与本阶段绑定
37-6. 提交:`integration: 沉积 Profile——dual-write 观察层`
38+5. 只有在 Phase 2.4 gates 全通过后,Phase 3 才允许开始;该前置条件已满足
39+
40+### Phase 3: 沉积 Profile 并行观测(已完成,Day 2)
41+
42+1. 从 Branch A 移植 `SedimentationProfile` dataclass 到 `cie/state.py`,新增 `sedimentation_profiles` 观测层状态
43+2. 保留 Branch B 的 `experience_hits` 作为已验证行为基线;Profile 保持 dual-write / parallel observation,不替换 `experience_hits`
44+3. 在 `activate()`、传播、feedback、decay、`sediment()` promotion-relevant 刷新中同时更新 Profile;`Dynamics.sediment()` 仍以 Branch B 真相源决定 `experience_regions` / `skill_belt_candidates` / `ability_cores`
45+4. 在 `snapshot_state()` 中输出 `sedimentation_profiles` 与 `sedimentation_profile_summary`,用于对照 Branch B 计数器与阶段推进
46+5. 新增 `tests/test_sedimentation_dualwrite.py`,覆盖小拓扑 reinforcement / inactivity / no-spurious-promotion / feedback observation
47+6. 通过 gate:
48+   `python3 -m pytest tests/test_smoke.py tests/test_dynamics.py tests/test_exactly_once.py tests/test_kernel_sanity.py tests/test_phase23_validation_sanity.py tests/test_phase24_kernel_math.py -q`
49+   `python3 -m pytest tests/test_sedimentation_dualwrite.py -q`
50+   `python3 -m pytest tests/test_smoke.py tests/test_dynamics.py tests/test_exactly_once.py tests/test_kernel_sanity.py tests/test_phase23_validation_sanity.py tests/test_phase24_kernel_math.py tests/test_comprehensive.py tests/test_sedimentation_dualwrite.py -q`
51+   `python3 tests/formal_validation.py`
52+7. 后续如需真正替换 `experience_hits`,必须在额外 gate 中证明行为保持一致后再做,不与本阶段绑定
53 
54 ### Phase 4: 参数统一(Day 2)
55 
M cie/__init__.py
+19, -2
 1@@ -1,7 +1,24 @@
 2 """CIE — Cognitive Inference Engine"""
 3 from .graph import Graph, Node, Edge
 4-from .state import CIEState, AttentionPool, PendingSignal
 5+from .state import (
 6+    CIEState,
 7+    AttentionPool,
 8+    PendingSignal,
 9+    SedimentationProfile,
10+    SEDIMENTATION_STAGE_ORDER,
11+)
12 from .dynamics import Dynamics
13 from .runtime import CIERuntime
14 
15-__all__ = ['Graph', 'Node', 'Edge', 'CIEState', 'AttentionPool', 'PendingSignal', 'Dynamics', 'CIERuntime']
16+__all__ = [
17+    'Graph',
18+    'Node',
19+    'Edge',
20+    'CIEState',
21+    'AttentionPool',
22+    'PendingSignal',
23+    'SedimentationProfile',
24+    'SEDIMENTATION_STAGE_ORDER',
25+    'Dynamics',
26+    'CIERuntime',
27+]
M cie/dynamics.py
+100, -1
  1@@ -8,7 +8,7 @@ CIE Dynamics — 动力学引擎
  2 import math
  3 import random
  4 from .graph import Graph
  5-from .state import CIEState
  6+from .state import CIEState, SEDIMENTATION_STAGE_ORDER
  7 
  8 
  9 class Dynamics:
 10@@ -35,6 +35,102 @@ class Dynamics:
 11         self.merge_threshold = 40   # 能力核合并阈值
 12         self.phi_damping = 0.02     # φ 全局阻尼——半杯水,不发散
 13 
 14+    def _authoritative_stage(self, node_id: str) -> str:
 15+        for core_nodes in self.state.ability_cores.values():
 16+            if node_id in core_nodes:
 17+                return 'ability_core'
 18+        if node_id in self.state.skill_belt_candidates:
 19+            return 'skill_belt'
 20+        if node_id in self.state.experience_regions.get('experience', set()):
 21+            return 'experience'
 22+        return 'memory'
 23+
 24+    def _authoritative_core_id(self, node_id: str) -> str | None:
 25+        for core_id, core_nodes in self.state.ability_cores.items():
 26+            if node_id in core_nodes:
 27+                return core_id
 28+        return None
 29+
 30+    def _profile_candidate_score(self, node_id: str, hits: int, recent_growth: int):
 31+        profile = self.state.ensure_sedimentation_profile(node_id)
 32+        stage_rank = SEDIMENTATION_STAGE_ORDER.index(profile.stage)
 33+        stage_bonus = (stage_rank / max(len(SEDIMENTATION_STAGE_ORDER) - 1, 1)) * 0.8
 34+        skill_score = min(self.state.skill_belt_candidates.get(node_id, 0.0), 1.5)
 35+        feedback_bonus = max(profile.recent_feedback, 0.0) * 0.12
 36+        decay_penalty = (
 37+            min(profile.decay_pressure, 4.0) * 0.18
 38+            + min(profile.dormant_steps, 6) * 0.15
 39+        )
 40+        return max(
 41+            0.0,
 42+            (min(hits, self.merge_threshold) / max(self.merge_threshold, 1)) * 1.4
 43+            + (min(recent_growth, self.sediment_threshold) / max(self.sediment_threshold, 1)) * 0.7
 44+            + (min(profile.settled_steps, self.skill_belt_threshold) / max(self.skill_belt_threshold, 1)) * 0.8
 45+            + skill_score
 46+            + stage_bonus
 47+            + feedback_bonus
 48+            - decay_penalty,
 49+        )
 50+
 51+    def _profile_promotion_signal(self, node_id: str, hits: int, recent_growth: int):
 52+        profile = self.state.ensure_sedimentation_profile(node_id)
 53+        stage_rank = SEDIMENTATION_STAGE_ORDER.index(profile.stage)
 54+        feedback_bonus = max(profile.recent_feedback, 0.0) * 0.08
 55+        decay_penalty = (
 56+            min(profile.decay_pressure, 4.0) * 0.1
 57+            + min(profile.dormant_steps, 6) * 0.08
 58+        )
 59+        return max(
 60+            0.0,
 61+            (hits / max(self.skill_belt_threshold, 1))
 62+            + (recent_growth / max(self.sediment_threshold, 1)) * 0.6
 63+            + min(self.state.skill_belt_candidates.get(node_id, 0.0), 1.0) * 0.6
 64+            + stage_rank * 0.15
 65+            + feedback_bonus
 66+            - decay_penalty,
 67+        )
 68+
 69+    def _refresh_sedimentation_profiles(self):
 70+        previous_hits = getattr(self, '_profile_last_hits', {})
 71+        all_nodes = (
 72+            set(self.graph.nodes)
 73+            | set(self.state.experience_hits)
 74+            | set(self.state.sedimentation_profiles)
 75+        )
 76+
 77+        for node_id in all_nodes:
 78+            profile = self.state.ensure_sedimentation_profile(node_id)
 79+            hits = self.state.experience_hits.get(node_id, 0)
 80+            recent_growth = max(0, hits - previous_hits.get(node_id, 0))
 81+            active_now = (
 82+                profile.last_active_step == self.state.step_count
 83+                or self.state.mu.get(node_id, 0.0) > 0.05
 84+                or recent_growth > 0
 85+            )
 86+
 87+            if active_now:
 88+                profile.settled_steps += 1
 89+                profile.dormant_steps = 0
 90+                profile.decay_pressure = max(0.0, profile.decay_pressure * 0.8 - 0.05)
 91+            else:
 92+                profile.dormant_steps += 1
 93+                profile.settled_steps = max(0, profile.settled_steps - 1)
 94+                profile.decay_pressure = min(10.0, profile.decay_pressure * 0.9 + 0.15)
 95+
 96+            old_stage = profile.stage
 97+            new_stage = self._authoritative_stage(node_id)
 98+            if new_stage != old_stage:
 99+                profile.stage = new_stage
100+                profile.last_transition_step = self.state.step_count
101+
102+            profile.authoritative_hits = hits
103+            profile.candidate_score = self._profile_candidate_score(node_id, hits, recent_growth)
104+            profile.promotion_signal = self._profile_promotion_signal(node_id, hits, recent_growth)
105+            profile.merged_into = self._authoritative_core_id(node_id)
106+            profile.last_updated_step = self.state.step_count
107+
108+        self._profile_last_hits = dict(self.state.experience_hits)
109+
110     # ── 图上扩散 ──
111 
112     def diffuse_phi(self):
113@@ -117,6 +213,7 @@ class Dynamics:
114                 self.state.experience_hits[nb] = (
115                     self.state.experience_hits.get(nb, 0) + 1
116                 )
117+                self.state.observe_sedimentation_touch(nb)
118                 self.state.active_region.add(nb)
119 
120             leftover = max(0.0, released - moved)
121@@ -185,6 +282,7 @@ class Dynamics:
122                 self.state.mu[node_id] = max(0.0, old_mu - decayed)
123                 if decayed > 1e-6:
124                     self.state.attention.release(node_id, decayed)
125+                    self.state.observe_sedimentation_decay(node_id, decayed, alpha)
126                     self.state.decay_events.append({
127                         'step': self.state.step_count,
128                         'node': node_id,
129@@ -363,6 +461,7 @@ class Dynamics:
130         if not hasattr(self, '_last_sed_hits'):
131             self._last_sed_hits = {}
132         self._last_sed_hits.update(last_sed_hits)
133+        self._refresh_sedimentation_profiles()
134 
135     # ── 边流衰减 ──
136 
M cie/runtime.py
+11, -0
 1@@ -197,6 +197,17 @@ class CIERuntime:
 2                     self.state.activate(token, feedback_amount)
 3                     self.state.phi[token] = self.state.phi.get(token, 0.0) + feedback_amount * 0.01
 4 
 5+        if signal.source == "feedback":
 6+            feedback_delta = signal.polarity * abs(signal.strength)
 7+            for token in tokens:
 8+                self.state.observe_sedimentation_feedback(token, feedback_delta)
 9+        elif signal.source == "emit":
10+            for token in tokens:
11+                self.state.observe_sedimentation_feedback(
12+                    token,
13+                    max(0.05, signal.strength * 0.2),
14+                )
15+
16     # ──────────────────────────────────────
17     # §5.3 emit — 生成输出
18     # ──────────────────────────────────────
M cie/state.py
+127, -0
  1@@ -28,6 +28,48 @@ class PendingSignal:
  2     metadata: dict = dc_field(default_factory=dict)
  3 
  4 
  5+SEDIMENTATION_STAGE_ORDER = ("memory", "experience", "skill_belt", "ability_core")
  6+
  7+
  8+@dataclass
  9+class SedimentationProfile:
 10+    """Phase 3: Branch A-style沉积观测层,不替代 Branch B 的真相源。"""
 11+    stage: str = "memory"
 12+    touch_count: int = 0
 13+    activation_count: int = 0
 14+    authoritative_hits: int = 0
 15+    settled_steps: int = 0
 16+    dormant_steps: int = 0
 17+    recent_feedback: float = 0.0
 18+    decay_pressure: float = 0.0
 19+    candidate_score: float = 0.0
 20+    promotion_signal: float = 0.0
 21+    last_updated_step: int = 0
 22+    last_active_step: int = -1
 23+    last_feedback_step: int = -1
 24+    last_transition_step: int = 0
 25+    merged_into: str | None = None
 26+
 27+    def to_dict(self) -> dict:
 28+        return {
 29+            'stage': self.stage,
 30+            'touch_count': self.touch_count,
 31+            'activation_count': self.activation_count,
 32+            'authoritative_hits': self.authoritative_hits,
 33+            'settled_steps': self.settled_steps,
 34+            'dormant_steps': self.dormant_steps,
 35+            'recent_feedback': round(self.recent_feedback, 4),
 36+            'decay_pressure': round(self.decay_pressure, 4),
 37+            'candidate_score': round(self.candidate_score, 4),
 38+            'promotion_signal': round(self.promotion_signal, 4),
 39+            'last_updated_step': self.last_updated_step,
 40+            'last_active_step': self.last_active_step,
 41+            'last_feedback_step': self.last_feedback_step,
 42+            'last_transition_step': self.last_transition_step,
 43+            'merged_into': self.merged_into,
 44+        }
 45+
 46+
 47 from collections import defaultdict
 48 import math
 49 import random
 50@@ -126,6 +168,7 @@ class CIEState:
 51         self.experience_hits: dict[str, int] = defaultdict(int)  # 节点被激活次数
 52         self.experience_regions: dict[str, set[str]] = {}        # region_id -> nodes
 53         self.skill_belt_candidates: dict[str, float] = {}        # node -> stability score
 54+        self.sedimentation_profiles: dict[str, SedimentationProfile] = {}
 55 
 56         # ── 输出模式 ──
 57         self.output_mode: str = 'full'  # full / degraded / minimal
 58@@ -216,6 +259,7 @@ class CIEState:
 59         self.mu[node_id] = self.mu.get(node_id, 0.0) + actual
 60         self.active_region.add(node_id)
 61         self.experience_hits[node_id] = self.experience_hits.get(node_id, 0) + 1
 62+        self.observe_sedimentation_touch(node_id)
 63         return actual
 64 
 65     def deactivate(self, node_id: str):
 66@@ -239,6 +283,52 @@ class CIEState:
 67         else:
 68             self.output_mode = 'minimal'
 69 
 70+    def ensure_sedimentation_profile(self, node_id: str) -> SedimentationProfile:
 71+        profile = self.sedimentation_profiles.get(node_id)
 72+        if profile is None:
 73+            profile = SedimentationProfile()
 74+            self.sedimentation_profiles[node_id] = profile
 75+        return profile
 76+
 77+    def observe_sedimentation_touch(self, node_id: str, feedback_delta: float = 0.0):
 78+        profile = self.ensure_sedimentation_profile(node_id)
 79+        profile.touch_count += 1
 80+        profile.activation_count += 1
 81+        profile.authoritative_hits = self.experience_hits.get(node_id, 0)
 82+        profile.last_active_step = self.step_count
 83+        profile.last_updated_step = self.step_count
 84+        if feedback_delta != 0.0:
 85+            profile.recent_feedback = max(
 86+                -10.0,
 87+                min(10.0, profile.recent_feedback * 0.7 + feedback_delta),
 88+            )
 89+            profile.last_feedback_step = self.step_count
 90+        return profile
 91+
 92+    def observe_sedimentation_feedback(self, node_id: str, delta: float):
 93+        profile = self.ensure_sedimentation_profile(node_id)
 94+        profile.recent_feedback = max(
 95+            -10.0,
 96+            min(10.0, profile.recent_feedback * 0.7 + delta),
 97+        )
 98+        profile.last_feedback_step = self.step_count
 99+        profile.last_updated_step = self.step_count
100+        if delta < 0.0:
101+            profile.decay_pressure = min(
102+                10.0,
103+                profile.decay_pressure * 0.9 + abs(delta) * 0.2,
104+            )
105+        return profile
106+
107+    def observe_sedimentation_decay(self, node_id: str, amount: float, alpha: float):
108+        profile = self.ensure_sedimentation_profile(node_id)
109+        profile.decay_pressure = min(
110+            10.0,
111+            profile.decay_pressure * 0.85 + abs(amount) * 0.05 + alpha,
112+        )
113+        profile.last_updated_step = self.step_count
114+        return profile
115+
116     # ── snapshot ──
117 
118     def snapshot(self) -> dict:
119@@ -259,6 +349,37 @@ class CIEState:
120             active_phi = sum(self.phi.get(n, 0.0) for n in self.active_region) if self.active_region else 0.0
121             anchor_pull = abs(anchor_phi - active_phi) / max(len(self.anchor_nodes), 1)
122 
123+        sorted_profiles = sorted(
124+            self.sedimentation_profiles.items(),
125+            key=lambda item: (
126+                -item[1].candidate_score,
127+                -item[1].authoritative_hits,
128+                -item[1].touch_count,
129+                item[0],
130+            ),
131+        )
132+        profile_stage_counts = {
133+            stage: sum(
134+                1 for profile in self.sedimentation_profiles.values()
135+                if profile.stage == stage
136+            )
137+            for stage in SEDIMENTATION_STAGE_ORDER
138+        }
139+        top_profiles = {
140+            node_id: profile.to_dict()
141+            for node_id, profile in sorted_profiles[:20]
142+        }
143+        top_candidates = [
144+            {
145+                'node': node_id,
146+                'stage': profile.stage,
147+                'candidate_score': round(profile.candidate_score, 4),
148+                'promotion_signal': round(profile.promotion_signal, 4),
149+                'authoritative_hits': profile.authoritative_hits,
150+            }
151+            for node_id, profile in sorted_profiles[:10]
152+        ]
153+
154         return {
155             'step_count': self.step_count,
156             'phi_summary': {
157@@ -286,6 +407,12 @@ class CIEState:
158             'skill_belt_candidates': dict(sorted(
159                 self.skill_belt_candidates.items(), key=lambda x: -x[1])[:10]),
160             'sedimentation_trace': self.sedimentation_trace[-20:],
161+            'sedimentation_profiles': top_profiles,
162+            'sedimentation_profile_summary': {
163+                'count': len(self.sedimentation_profiles),
164+                'by_stage': profile_stage_counts,
165+                'top_candidates': top_candidates,
166+            },
167             'merge_events': self.merge_events[-10:],
168             'decay_events': self.decay_events[-10:],
169             'output_mode': self.output_mode,
A tests/test_sedimentation_dualwrite.py
+124, -0
  1@@ -0,0 +1,124 @@
  2+"""
  3+Phase 3: SedimentationProfile dual-write / parallel observation tests.
  4+
  5+Boundary-focused checks:
  6+1. Small controlled reinforcement advances both Branch B sedimentation truth and the profile layer
  7+2. Inactivity on an upstream node raises decay pressure when Branch B hits stop advancing there
  8+3. Insufficient stimulation does not create a spurious strong promotion signal
  9+4. Feedback updates the profile observation layer through the real signal queue
 10+"""
 11+
 12+import os
 13+import sys
 14+
 15+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 16+
 17+from cie import CIERuntime
 18+
 19+
 20+def _build_controlled_runtime(*, cyclic: bool) -> CIERuntime:
 21+    rt = CIERuntime(seed=42)
 22+    for node_id in ["a", "b", "c", "d"]:
 23+        rt.graph.add_node(node_id)
 24+        rt.state.init_node(node_id, phi_val=0.0)
 25+
 26+    if cyclic:
 27+        rt.graph.add_edge("a", "b", weight=2.0, bwd_weight=0.5)
 28+        rt.graph.add_edge("b", "c", weight=1.5, bwd_weight=0.3)
 29+        rt.graph.add_edge("c", "a", weight=1.2, bwd_weight=0.2)
 30+        rt.graph.add_edge("c", "d", weight=0.8, bwd_weight=0.1)
 31+    else:
 32+        rt.graph.add_edge("a", "b", weight=1.5, bwd_weight=0.1)
 33+        rt.graph.add_edge("b", "c", weight=1.2, bwd_weight=0.1)
 34+        rt.graph.add_edge("c", "d", weight=1.0, bwd_weight=0.1)
 35+
 36+    rt.dynamics.sediment_threshold = 4
 37+    rt.dynamics.skill_belt_threshold = 8
 38+    rt.dynamics.merge_threshold = 12
 39+    return rt
 40+
 41+
 42+def test_controlled_reinforcement_dual_writes_profile_and_truth():
 43+    rt = _build_controlled_runtime(cyclic=True)
 44+
 45+    for _ in range(8):
 46+        rt.state.activate("a", 1.5)
 47+        rt.dynamics.step()
 48+
 49+    authoritative_hits = rt.state.experience_hits["a"]
 50+    profile_a = rt.state.sedimentation_profiles["a"]
 51+    profile_d = rt.state.sedimentation_profiles["d"]
 52+    snapshot = rt.snapshot_state()
 53+
 54+    assert authoritative_hits >= rt.dynamics.skill_belt_threshold
 55+    assert "a" in rt.state.experience_regions.get("experience", set())
 56+    assert "a" in rt.state.skill_belt_candidates
 57+
 58+    assert profile_a.authoritative_hits == authoritative_hits
 59+    assert profile_a.touch_count >= authoritative_hits
 60+    assert profile_a.stage in {"experience", "skill_belt", "ability_core"}
 61+    assert profile_a.candidate_score > profile_d.candidate_score
 62+    assert profile_a.promotion_signal > profile_d.promotion_signal
 63+
 64+    assert "sedimentation_profiles" in snapshot
 65+    assert "sedimentation_profile_summary" in snapshot
 66+    assert snapshot["sedimentation_profiles"]["a"]["authoritative_hits"] == authoritative_hits
 67+
 68+
 69+def test_inactivity_raises_decay_pressure_when_upstream_hits_stop():
 70+    rt = _build_controlled_runtime(cyclic=False)
 71+
 72+    for _ in range(4):
 73+        rt.state.activate("a", 1.2)
 74+        rt.dynamics.step()
 75+
 76+    hits_before = dict(rt.state.experience_hits)
 77+    profile_before = rt.state.sedimentation_profiles["a"].to_dict()
 78+
 79+    for _ in range(12):
 80+        rt.dynamics.step()
 81+
 82+    hits_after = dict(rt.state.experience_hits)
 83+    profile_after = rt.state.sedimentation_profiles["a"]
 84+
 85+    assert hits_after["a"] == hits_before["a"]
 86+    assert profile_after.authoritative_hits == hits_after["a"]
 87+    assert profile_after.dormant_steps > profile_before["dormant_steps"]
 88+    assert profile_after.decay_pressure > profile_before["decay_pressure"]
 89+    assert profile_after.promotion_signal < profile_before["promotion_signal"]
 90+
 91+
 92+def test_insufficient_stimulation_does_not_fake_promotion():
 93+    rt = _build_controlled_runtime(cyclic=False)
 94+
 95+    for _ in range(2):
 96+        rt.state.activate("a", 1.0)
 97+        rt.dynamics.step()
 98+
 99+    profile_a = rt.state.sedimentation_profiles["a"]
100+
101+    assert rt.state.experience_hits["a"] < rt.dynamics.sediment_threshold
102+    assert not rt.state.experience_regions
103+    assert not rt.state.skill_belt_candidates
104+    assert not rt.state.ability_cores
105+
106+    assert profile_a.stage == "memory"
107+    assert profile_a.candidate_score < 1.0
108+    assert profile_a.promotion_signal < 1.0
109+
110+
111+def test_feedback_updates_profile_via_real_signal_queue():
112+    rt = _build_controlled_runtime(cyclic=False)
113+
114+    rt.state.activate("a", 1.0)
115+    rt.dynamics.step()
116+    before = rt.state.sedimentation_profiles["a"].to_dict()
117+
118+    rt.commit_feedback({"correct": ["a"], "reward": 1.0})
119+    assert len(rt.state.pending_signals) >= 1
120+    rt.step(n=1)
121+
122+    after = rt.state.sedimentation_profiles["a"]
123+    assert after.recent_feedback > before["recent_feedback"]
124+    assert after.last_feedback_step >= before["last_feedback_step"]
125+    assert after.authoritative_hits > before["authoritative_hits"]