CIE-Unified

git clone 

commit
164c06a
parent
9823c23
author
codex@macbookpro
date
2026-03-31 17:23:11 +0800 CST
branch-a: task03 sedimentation + skill belt + merge events
8 files changed,  +469, -61
M cie/runtime.py
+245, -53
  1@@ -3,7 +3,7 @@ from __future__ import annotations
  2 import re
  3 from typing import Any, Dict, Iterable, List
  4 
  5-from .state import PendingSignal, RuntimeState
  6+from .state import PendingSignal, RuntimeState, SedimentationProfile
  7 
  8 REQUIRED_SNAPSHOT_KEYS = {
  9     "phi_summary",
 10@@ -189,6 +189,7 @@ class CIERuntime:
 11         self._propagate_activation()
 12         self._apply_homing()
 13         self._apply_decay()
 14+        self._refresh_sedimentation()
 15         self._refresh_observability()
 16 
 17     def _apply_signal(self, signal: PendingSignal) -> None:
 18@@ -198,6 +199,7 @@ class CIERuntime:
 19             self.state.phi.setdefault(node, 0.0)
 20             self.state.mu.setdefault(node, 0.0)
 21             self.state.strata.setdefault(node, "memory")
 22+            self._ensure_profile(node)
 23             self.state.touch_count[node] = self.state.touch_count.get(node, 0) + 1
 24             self.state.node_last_touched[node] = self.state.step_index
 25         for anchor in signal.anchor_tokens:
 26@@ -218,7 +220,6 @@ class CIERuntime:
 27                 self.state.mu[token] = max(0.0, self.state.mu.get(token, 0.0) - (0.6 * activation_gain))
 28                 self.state.phi[token] = max(0.0, self.state.phi.get(token, 0.0) - (0.4 * potential_gain))
 29                 self._record_decay("feedback_suppression", token, activation_gain * 0.6, age=0)
 30-            self._update_sedimentation(token)
 31         if signal.source in {"emit", "feedback"}:
 32             self._apply_feedback_signal(signal)
 33 
 34@@ -252,6 +253,7 @@ class CIERuntime:
 35             self.state.phi[node] = self.state.phi.get(node, 0.0) + retained * 0.04
 36         for node, gained in incoming.items():
 37             self.state.node_last_touched[node] = self.state.step_index
 38+            self._ensure_profile(node)
 39             if gained > 0.12:
 40                 self.state.touch_count[node] = self.state.touch_count.get(node, 0) + 1
 41             recurrence = min(self.state.touch_count.get(node, 0), 4)
 42@@ -259,7 +261,6 @@ class CIERuntime:
 43             if node in self.state.anchor_nodes:
 44                 phi_gain += 0.03
 45             self.state.phi[node] = self.state.phi.get(node, 0.0) + phi_gain
 46-            self._update_sedimentation(node)
 47         self.state.mu = {node: value for node, value in next_mu.items() if value >= 0.02}
 48 
 49     def _apply_homing(self) -> None:
 50@@ -353,41 +354,191 @@ class CIERuntime:
 51         self.state.active_region = self._top_nodes(self.state.mu, limit=4)
 52         self.state.bound_ability_core = self._select_bound_core()
 53         self.state.drift_score = self._compute_drift_score()
 54+        self.state.anchor_pull = self._compute_anchor_pull()
 55         total_activation = sum(self.state.mu.values())
 56         self.state.free_capacity = max(0.0, 1.0 - min(total_activation / self.capacity_limit, 1.0))
 57         self.state.confidence_proxy = self._confidence_proxy()
 58         self.state.output_mode = self._choose_output_mode()
 59+        if self.state.feedback_effect.get("last_applied_step") == self.state.step_index:
 60+            applied = self.state.feedback_effect.get("applied_tokens", [])
 61+            self.state.feedback_effect["stage_after"] = {
 62+                node: self._ensure_profile(node).stage for node in applied
 63+            }
 64+            self.state.feedback_effect["bound_ability_core"] = self.state.bound_ability_core
 65+
 66+    def _ensure_profile(self, node: str) -> SedimentationProfile:
 67+        profile = self.state.sedimentation.get(node)
 68+        if profile is None:
 69+            profile = SedimentationProfile(stage=self.state.strata.get(node, "memory"))
 70+            self.state.sedimentation[node] = profile
 71+        self.state.strata[node] = profile.stage
 72+        return profile
 73 
 74-    def _update_sedimentation(self, node: str) -> None:
 75-        old_stage = self.state.strata.get(node, "memory")
 76+    def _refresh_sedimentation(self) -> None:
 77+        for node in self.state.graph.nodes():
 78+            profile = self._ensure_profile(node)
 79+            support = self._sedimentation_support(node)
 80+            activation = self.state.mu.get(node, 0.0)
 81+            touched_now = self.state.node_last_touched.get(node, -1) == self.state.step_index
 82+            stable_now = touched_now or activation >= 0.16 or (activation >= 0.08 and support >= 0.34)
 83+            if touched_now and profile.last_active_step != self.state.step_index:
 84+                profile.activation_hits += 1
 85+                profile.last_active_step = self.state.step_index
 86+            if stable_now:
 87+                profile.stable_steps += 1
 88+                profile.dormant_steps = 0
 89+                profile.resonance = min(6.0, (profile.resonance * 0.84) + support)
 90+            else:
 91+                profile.dormant_steps += 1
 92+                profile.stable_steps = max(0, profile.stable_steps - 1)
 93+                profile.resonance = max(0.0, profile.resonance * 0.7)
 94+            profile.candidate_score = self._candidate_score(node, profile)
 95+            self._update_stage_from_profile(node, profile)
 96+
 97+    def _sedimentation_support(self, node: str) -> float:
 98+        activation = self.state.mu.get(node, 0.0)
 99+        potential = self.state.phi.get(node, 0.0)
100+        flow = self._node_flow(node)
101+        return activation + (0.35 * potential) + (0.18 * flow)
102+
103+    def _candidate_score(self, node: str, profile: SedimentationProfile) -> float:
104+        touches = min(self.state.touch_count.get(node, 0), 8)
105+        flow = min(self._node_flow(node), 3.0)
106+        potential = min(self.state.phi.get(node, 0.0), 3.0)
107+        score = (
108+            (0.12 * touches)
109+            + (0.16 * min(profile.activation_hits, 8))
110+            + (0.18 * min(profile.stable_steps, 8))
111+            + (0.32 * min(profile.resonance, 3.0))
112+            + (0.12 * flow)
113+            + (0.08 * potential)
114+        )
115+        if node in self.state.anchor_nodes:
116+            score += 0.05
117+        return _round(score)
118+
119+    def _desired_stage(self, node: str, profile: SedimentationProfile) -> str:
120         touches = self.state.touch_count.get(node, 0)
121-        phi = self.state.phi.get(node, 0.0)
122         flow = self._node_flow(node)
123-        if touches >= 6 or phi >= 1.3 or flow >= 1.2:
124-            new_stage = "ability_core"
125-        elif touches >= 4 or phi >= 0.85 or flow >= 0.7:
126-            new_stage = "skill_belt"
127-        elif touches >= 2 or phi >= 0.35 or flow >= 0.25:
128-            new_stage = "experience"
129-        else:
130-            new_stage = "memory"
131-        if STAGE_ORDER.index(new_stage) > STAGE_ORDER.index(old_stage):
132-            self.state.strata[node] = new_stage
133-            event = {
134+        score = self._effective_candidate_score(profile)
135+        if score >= 2.2 and touches >= 5 and profile.stable_steps >= 4 and flow >= 0.45:
136+            return "ability_core"
137+        if score >= 1.35 and touches >= 3 and profile.stable_steps >= 2:
138+            return "skill_belt"
139+        if score >= 0.55 and touches >= 1:
140+            return "experience"
141+        return "memory"
142+
143+    def _update_stage_from_profile(self, node: str, profile: SedimentationProfile) -> None:
144+        current_index = STAGE_ORDER.index(profile.stage)
145+        target_stage = self._desired_stage(node, profile)
146+        target_index = STAGE_ORDER.index(target_stage)
147+        new_stage: str | None = None
148+        reason = "stability"
149+        if target_index > current_index:
150+            new_stage = STAGE_ORDER[current_index + 1]
151+            reason = "promotion"
152+        elif target_index < current_index and self._can_demote(profile):
153+            new_stage = STAGE_ORDER[current_index - 1]
154+            reason = "decay"
155+        if new_stage is None or new_stage == profile.stage:
156+            self.state.strata[node] = profile.stage
157+            return
158+        old_stage = profile.stage
159+        profile.stage = new_stage
160+        profile.last_transition_step = self.state.step_index
161+        if new_stage == "ability_core":
162+            profile.merged_into = self._record_merge_event(node, profile)
163+        elif STAGE_ORDER.index(new_stage) < STAGE_ORDER.index(old_stage):
164+            if new_stage != "ability_core":
165+                profile.merged_into = None
166+            self._record_decay(
167+                "sedimentation_demote",
168+                node,
169+                max(0.01, profile.candidate_score),
170+                age=profile.dormant_steps,
171+            )
172+        self.state.strata[node] = new_stage
173+        self.state.sedimentation_trace.append(
174+            {
175                 "step": self.state.step_index,
176                 "node": node,
177+                "direction": "promote" if reason == "promotion" else "demote",
178                 "from": old_stage,
179                 "to": new_stage,
180-                "phi": _round(phi),
181-                "flow": _round(flow),
182+                "touches": self.state.touch_count.get(node, 0),
183+                "stable_steps": profile.stable_steps,
184+                "dormant_steps": profile.dormant_steps,
185+                "candidate_score": profile.candidate_score,
186+                "resonance": _round(profile.resonance),
187+                "flow": _round(self._node_flow(node)),
188             }
189-            self.state.sedimentation_trace.append(event)
190-            self.state.sedimentation_trace = self.state.sedimentation_trace[-12:]
191-            if new_stage == "ability_core":
192-                self.state.merge_events.append(
193-                    {"step": self.state.step_index, "node": node, "event": "skill_to_core"}
194-                )
195-                self.state.merge_events = self.state.merge_events[-8:]
196+        )
197+        self.state.sedimentation_trace = self.state.sedimentation_trace[-20:]
198+
199+    def _can_demote(self, profile: SedimentationProfile) -> bool:
200+        stage = profile.stage
201+        score = self._effective_candidate_score(profile)
202+        if stage == "ability_core":
203+            return profile.dormant_steps >= 3 and score < 1.85
204+        if stage == "skill_belt":
205+            return profile.dormant_steps >= 2 and score < 1.1
206+        if stage == "experience":
207+            return profile.dormant_steps >= 2 and score < 0.45
208+        return False
209+
210+    def _effective_candidate_score(self, profile: SedimentationProfile) -> float:
211+        return max(0.0, profile.candidate_score - (0.35 * profile.dormant_steps))
212+
213+    def _record_merge_event(self, node: str, profile: SedimentationProfile) -> str:
214+        support_nodes = []
215+        for neighbor in self.state.graph.neighbors(node):
216+            neighbor_profile = self._ensure_profile(neighbor)
217+            if neighbor_profile.stage in {"skill_belt", "ability_core"}:
218+                support_nodes.append(neighbor)
219+        target_core = None
220+        ability_cores = [
221+            candidate
222+            for candidate, candidate_profile in self.state.sedimentation.items()
223+            if candidate != node and candidate_profile.stage == "ability_core"
224+        ]
225+        if ability_cores:
226+            target_core = max(
227+                ability_cores,
228+                key=lambda candidate: (
229+                    self._ensure_profile(candidate).candidate_score,
230+                    self.state.phi.get(candidate, 0.0),
231+                    candidate,
232+                ),
233+            )
234+        else:
235+            target_core = node
236+        event = {
237+            "step": self.state.step_index,
238+            "event": "skill_belt_merge",
239+            "node": node,
240+            "target_core": target_core,
241+            "support_nodes": sorted(support_nodes)[:4],
242+            "candidate_score": profile.candidate_score,
243+            "stable_steps": profile.stable_steps,
244+        }
245+        self.state.merge_events.append(event)
246+        self.state.merge_events = self.state.merge_events[-12:]
247+        return target_core
248+
249+    def _compute_anchor_pull(self) -> float:
250+        if not self.state.anchor_nodes or not self.state.mu:
251+            return 0.0
252+        total_activation = sum(self.state.mu.values()) or 1.0
253+        anchor_mass = sum(self.state.mu.get(anchor, 0.0) for anchor in self.state.anchor_nodes)
254+        core = self.state.bound_ability_core
255+        core_anchor_flow = 0.0
256+        if core:
257+            for anchor in self.state.anchor_nodes:
258+                core_anchor_flow += self.state.J.get((core, anchor), 0.0)
259+                core_anchor_flow += self.state.J.get((anchor, core), 0.0)
260+        pull = (anchor_mass / total_activation) + min(core_anchor_flow, 1.0) * 0.2
261+        return min(1.0, pull)
262 
263     def _record_decay(self, kind: str, target: str, amount: float, *, age: int) -> None:
264         self.state.decay_events.append(
265@@ -399,7 +550,7 @@ class CIERuntime:
266                 "age": age,
267             }
268         )
269-        self.state.decay_events = self.state.decay_events[-20:]
270+        self.state.decay_events = self.state.decay_events[-24:]
271 
272     def _select_bound_core(self) -> str | None:
273         nodes = self.state.graph.nodes()
274@@ -409,12 +560,12 @@ class CIERuntime:
275         return max(
276             nodes,
277             key=lambda node: (
278-                stage_rank.get(self.state.strata.get(node, "memory"), 0),
279-                self.state.phi.get(node, 0.0)
280-                + (0.24 * self.state.touch_count.get(node, 0))
281-                + (0.14 * self._node_flow(node))
282-                + (0.08 * self.state.graph.weighted_degree(node))
283-                + (0.12 * self.state.anchor_nodes.get(node, 0.0)),
284+                stage_rank.get(self._ensure_profile(node).stage, 0),
285+                self._effective_candidate_score(self._ensure_profile(node))
286+                + (0.16 * self.state.phi.get(node, 0.0))
287+                + (0.12 * self._node_flow(node))
288+                + (0.06 * self.state.graph.weighted_degree(node))
289+                + (0.08 * self.state.anchor_nodes.get(node, 0.0)),
290                 self.state.mu.get(node, 0.0),
291                 node,
292             ),
293@@ -509,42 +660,83 @@ class CIERuntime:
294         }
295 
296     def _experience_regions(self) -> List[Dict[str, Any]]:
297-        items: List[Dict[str, Any]] = []
298-        for node, stage in self.state.strata.items():
299-            if stage not in {"experience", "skill_belt", "ability_core"}:
300+        groups: Dict[str, Dict[str, Any]] = {}
301+        for node, profile in self.state.sedimentation.items():
302+            if profile.stage not in {"experience", "skill_belt", "ability_core"}:
303                 continue
304+            region = self._region_seed(node)
305+            entry = groups.setdefault(
306+                region,
307+                {
308+                    "region": region,
309+                    "nodes": [],
310+                    "stage": profile.stage,
311+                    "activation": 0.0,
312+                    "potential": 0.0,
313+                    "candidate_score": 0.0,
314+                    "stable_steps": 0,
315+                },
316+            )
317+            entry["nodes"].append(node)
318+            if STAGE_ORDER.index(profile.stage) > STAGE_ORDER.index(entry["stage"]):
319+                entry["stage"] = profile.stage
320+            entry["activation"] += self.state.mu.get(node, 0.0)
321+            entry["potential"] += self.state.phi.get(node, 0.0)
322+            entry["candidate_score"] += profile.candidate_score
323+            entry["stable_steps"] = max(entry["stable_steps"], profile.stable_steps)
324+        items = []
325+        for entry in groups.values():
326             items.append(
327                 {
328-                    "region": node,
329-                    "stage": stage,
330-                    "activation": _round(self.state.mu.get(node, 0.0)),
331-                    "potential": _round(self.state.phi.get(node, 0.0)),
332-                    "touches": self.state.touch_count.get(node, 0),
333+                    "region": entry["region"],
334+                    "nodes": sorted(entry["nodes"]),
335+                    "stage": entry["stage"],
336+                    "activation": _round(entry["activation"]),
337+                    "potential": _round(entry["potential"]),
338+                    "candidate_score": _round(entry["candidate_score"]),
339+                    "stable_steps": entry["stable_steps"],
340                 }
341             )
342-        return sorted(items, key=lambda item: (-item["activation"], item["region"]))[:6]
343+        return sorted(items, key=lambda item: (-item["candidate_score"], item["region"]))[:6]
344 
345     def _skill_belt_candidates(self) -> List[Dict[str, Any]]:
346         items: List[Dict[str, Any]] = []
347-        for node in self.state.graph.nodes():
348-            stage = self.state.strata.get(node, "memory")
349-            if stage not in {"skill_belt", "ability_core"} and self.state.touch_count.get(node, 0) < 2:
350+        for node, profile in self.state.sedimentation.items():
351+            if profile.stage == "memory" and profile.candidate_score < 0.9:
352                 continue
353-            score = (
354-                self.state.phi.get(node, 0.0)
355-                + (0.22 * self.state.touch_count.get(node, 0))
356-                + (0.12 * self._node_flow(node))
357-            )
358             items.append(
359                 {
360                     "node": node,
361-                    "score": _round(score),
362-                    "stage": stage,
363+                    "score": profile.candidate_score,
364+                    "stage": profile.stage,
365                     "flow": _round(self._node_flow(node)),
366+                    "stable_steps": profile.stable_steps,
367+                    "touches": self.state.touch_count.get(node, 0),
368+                    "target_core": profile.merged_into or self._region_seed(node),
369                 }
370             )
371         return sorted(items, key=lambda item: (-item["score"], item["node"]))[:6]
372 
373+    def _region_seed(self, node: str) -> str:
374+        profile = self._ensure_profile(node)
375+        if profile.merged_into:
376+            return profile.merged_into
377+        if profile.stage == "ability_core":
378+            return node
379+        candidates = []
380+        for neighbor in self.state.graph.neighbors(node):
381+            neighbor_profile = self._ensure_profile(neighbor)
382+            if neighbor_profile.stage == "ability_core":
383+                candidates.append(
384+                    (
385+                        neighbor_profile.candidate_score + self.state.phi.get(neighbor, 0.0),
386+                        neighbor,
387+                    )
388+                )
389+        if candidates:
390+            return max(candidates)[1]
391+        return self.state.bound_ability_core or node
392+
393     def _apply_feedback_signal(self, signal: PendingSignal) -> None:
394         effect = dict(self.state.feedback_effect)
395         mode = str(signal.metadata.get("mode", "feedback"))
396@@ -561,6 +753,7 @@ class CIERuntime:
397             self.state.graph.ensure_node(node)
398             self.state.phi.setdefault(node, 0.0)
399             self.state.mu.setdefault(node, 0.0)
400+            self._ensure_profile(node)
401             if signal.polarity >= 0:
402                 phi_gain = 0.08 * weight
403                 mu_gain = 0.11 * weight
404@@ -577,7 +770,6 @@ class CIERuntime:
405                 mu_delta -= mu_loss
406             self.state.node_last_touched[node] = self.state.step_index
407             applied_tokens.append(node)
408-            self._update_sedimentation(node)
409         for left, right in zip(applied_tokens, applied_tokens[1:]):
410             gain = 0.09 * signal.strength * mode_scale * signal.polarity
411             self.state.J[(left, right)] = max(0.0, self.state.J.get((left, right), 0.0) + gain)
M cie/state.py
+14, -0
 1@@ -17,6 +17,19 @@ class PendingSignal:
 2     metadata: Dict[str, Any] = field(default_factory=dict)
 3 
 4 
 5+@dataclass
 6+class SedimentationProfile:
 7+    stage: str = "memory"
 8+    activation_hits: int = 0
 9+    stable_steps: int = 0
10+    dormant_steps: int = 0
11+    resonance: float = 0.0
12+    candidate_score: float = 0.0
13+    last_active_step: int = -1
14+    last_transition_step: int = 0
15+    merged_into: str | None = None
16+
17+
18 @dataclass
19 class RuntimeState:
20     phi: Dict[str, float] = field(default_factory=dict)
21@@ -25,6 +38,7 @@ class RuntimeState:
22     graph: Graph = field(default_factory=Graph)
23     anchor_nodes: Dict[str, float] = field(default_factory=dict)
24     strata: Dict[str, str] = field(default_factory=dict)
25+    sedimentation: Dict[str, SedimentationProfile] = field(default_factory=dict)
26     touch_count: Dict[str, int] = field(default_factory=dict)
27     node_last_touched: Dict[str, int] = field(default_factory=dict)
28     edge_last_touched: Dict[Tuple[str, str], int] = field(default_factory=dict)
M plans/2026-03-31_branch_a_plan.md
+2, -2
 1@@ -17,7 +17,7 @@ Branch A is the pure graph-native minimal runtime line:
 2    Repo-native scaffold, locked interface, and smoke coverage are in place.
 3 2. **Task 02: minimal dynamics + feedback loop + decay + degraded output** `[completed]`
 4    Runtime dynamics, output feedback, observable decay, and mode degradation are now implemented and validated.
 5-3. **Task 03: sedimentation path + skill belt candidates + merge/decay events**
 6-   Expand the memory-to-experience-to-skill-to-core path and make event traces more meaningful.
 7+3. **Task 03: sedimentation path + skill belt candidates + merge/decay events** `[completed]`
 8+   Explicit sedimentation profiles, stage transitions, merge events, and decay-linked demotion are now implemented and validated.
 9 4. **Task 04: unified validation/reporting against locked spec**
10    Produce the comparable validation and reporting layer required by the locked docs.
M tasks/2026-03-31_task02_branch_a_dynamics_feedback.md
+4, -0
1@@ -118,3 +118,7 @@ Those documents remain locked and define the Branch A requirements for graph-nat
2   - sedimentation still uses lightweight stage heuristics rather than a richer path-level memory-to-skill trace
3   - merge events remain node-level promotions, not broader structural merges or loop detection
4   - feedback is now real and stateful but still local; it does not yet model richer long-horizon teacher correction
5+
6+## Task 03 Continuation Note
7+
8+Task 03 for Branch A was branched independently from commit `9823c2395c565908965b137e343bcbc1883ae3c5` on `branch-a/task03-sedimentation` to replace those sedimentation placeholders with explicit runtime state and events.
A tasks/2026-03-31_task03_branch_a_sedimentation.md
+122, -0
  1@@ -0,0 +1,122 @@
  2+# Task 03: Branch A Sedimentation Path
  3+
  4+## Title
  5+
  6+Task 03: sedimentation path + skill belt candidates + merge/decay events
  7+
  8+## Direct Prompt
  9+
 10+Continue Branch A from commit `9823c2395c565908965b137e343bcbc1883ae3c5`, keep the implementation independent from other branches, turn sedimentation from heuristic placeholders into explicit runtime structures and events, make `memory -> experience -> skill_belt -> ability_core` visible in state, and record execution results in-repo.
 11+
 12+## Suggested Branch Name
 13+
 14+`branch-a/task03-sedimentation`
 15+
 16+## Goal
 17+
 18+Implement the smallest inspectable Branch A sedimentation model that keeps the runtime graph-native, makes stage progression explicit, exposes skill-belt candidacy and ability-core merges as real events, and lets decay/inactivity demote developing structures.
 19+
 20+## Background
 21+
 22+This round continues to follow the locked conceptual and engineering constraints in:
 23+
 24+- `/Users/george/code/CIE-Unified/README.md`
 25+- `/Users/george/code/CIE-Unified/LOCKED_IMPLEMENTATION_SPEC.md`
 26+
 27+Those documents remain locked and define the Branch A requirements for graph-native runtime state, the fixed sedimentation path, decay, homing, and comparable observability.
 28+
 29+## Involved Repo
 30+
 31+- `/Users/george/code/CIE-Unified`
 32+
 33+## Scope
 34+
 35+- update the Branch A plan/task docs for round 3
 36+- replace sedimentation stage placeholders with explicit runtime sedimentation profiles
 37+- make `memory -> experience -> skill_belt -> ability_core` progression visible and evented
 38+- derive `skill_belt_candidates` from repeated activation, stability, and recurrence
 39+- record merge events when skill-belt structures promote into ability-core-level structures
 40+- make decay/inactivity interact with sedimentation state through explicit demotion behavior
 41+- strengthen snapshot observability while keeping the runtime small and stdlib-only
 42+- extend tests for traces, candidates, merges, and decay interaction
 43+
 44+## Allowed Modifications
 45+
 46+- `/Users/george/code/CIE-Unified/plans/2026-03-31_branch_a_plan.md`
 47+- `/Users/george/code/CIE-Unified/tasks/2026-03-31_task02_branch_a_dynamics_feedback.md`
 48+- `/Users/george/code/CIE-Unified/tasks/2026-03-31_task03_branch_a_sedimentation.md`
 49+- `/Users/george/code/CIE-Unified/cie/__init__.py`
 50+- `/Users/george/code/CIE-Unified/cie/graph.py`
 51+- `/Users/george/code/CIE-Unified/cie/state.py`
 52+- `/Users/george/code/CIE-Unified/cie/runtime.py`
 53+- `/Users/george/code/CIE-Unified/tests/__init__.py`
 54+- `/Users/george/code/CIE-Unified/tests/test_smoke.py`
 55+- `/Users/george/code/CIE-Unified/tests/test_dynamics.py`
 56+- `/Users/george/code/CIE-Unified/tests/test_sedimentation.py`
 57+
 58+## Avoid Modifying
 59+
 60+- `/Users/george/code/CIE-Unified/README.md`
 61+- `/Users/george/code/CIE-Unified/LOCKED_IMPLEMENTATION_SPEC.md`
 62+
 63+## Must Complete
 64+
 65+- mark Task 02 complete and Task 03 complete in the Branch A plan
 66+- create this Task 03 prompt document in-repo
 67+- implement explicit sedimentation runtime state and stage transitions
 68+- make `skill_belt_candidates`, `merge_events`, and `decay_events` materially meaningful
 69+- keep `snapshot_state()` aligned with the locked observable fields
 70+- validate with the recommended unittest command
 71+- record execution details and deferred limitations here
 72+
 73+## Acceptance Criteria
 74+
 75+1. runtime has explicit, inspectable sedimentation-stage representation aligned to `memory -> experience -> skill_belt -> ability_core`
 76+2. `sedimentation_trace` records stage transitions as real runtime events, not comments/placeholders only
 77+3. repeated activation can promote structures toward `skill_belt_candidates`
 78+4. stable/repeated skill-belt structures can generate `merge_events` into ability-core level structures
 79+5. decay/inactivity can generate `decay_events` that interact with sedimentation state
 80+6. `snapshot_state` keeps required locked fields and makes them more meaningful
 81+7. tests pass
 82+
 83+## Evaluation Requirements
 84+
 85+- use only the Python standard library in runtime code
 86+- keep the implementation minimal, explicit, and inspectable
 87+- stay graph-native with `(phi, mu, J)` as the canonical state
 88+- avoid `exact_text_map`
 89+- avoid MoE-style substitution
 90+- avoid latent-vector ontology as the real runtime state
 91+- keep observability stronger than hidden heuristics
 92+
 93+## Recommended Validation Command
 94+
 95+`python3 -m unittest discover -s tests -v`
 96+
 97+## Delivery Requirements
 98+
 99+- commit on `branch-a/task03-sedimentation`
100+- push the branch to `origin`
101+- keep the implementation independent from `branch-b` and other later branches
102+- include execution record details for branch/base/backup/files/validation/results/limitations
103+
104+## Execution Record
105+
106+- actual branch name: `branch-a/task03-sedimentation`
107+- base commit: `9823c2395c565908965b137e343bcbc1883ae3c5`
108+- backup path used for dirty-worktree handling: `none`
109+- files changed:
110+  - `/Users/george/code/CIE-Unified/plans/2026-03-31_branch_a_plan.md`
111+  - `/Users/george/code/CIE-Unified/tasks/2026-03-31_task02_branch_a_dynamics_feedback.md`
112+  - `/Users/george/code/CIE-Unified/tasks/2026-03-31_task03_branch_a_sedimentation.md`
113+  - `/Users/george/code/CIE-Unified/cie/state.py`
114+  - `/Users/george/code/CIE-Unified/cie/runtime.py`
115+  - `/Users/george/code/CIE-Unified/tests/test_smoke.py`
116+  - `/Users/george/code/CIE-Unified/tests/test_dynamics.py`
117+  - `/Users/george/code/CIE-Unified/tests/test_sedimentation.py`
118+- validation command run: `python3 -m unittest discover -s tests -v`
119+- concise test result summary: `Ran 14 tests; all passed.`
120+- remaining limitations deferred to Task 04:
121+  - unified validation/reporting against the locked comparison format is still pending
122+  - sedimentation merge logic is explicit and inspectable, but still heuristic and node/region centric rather than richer loop-level structure analysis
123+  - snapshot observability is ready for Task 04, but the final cross-branch report/export layer is not yet implemented
M tests/test_dynamics.py
+11, -6
 1@@ -21,18 +21,21 @@ class RuntimeDynamicsTests(unittest.TestCase):
 2         )
 3         self.assertNotEqual(step_one["J_summary"]["total_flow"], step_three["J_summary"]["total_flow"])
 4         self.assertTrue(step_three["sedimentation_trace"])
 5+        self.assertIn("direction", step_three["sedimentation_trace"][0])
 6 
 7     def test_decay_events_occur_for_inactive_structures(self) -> None:
 8-        runtime = CIERuntime(capacity_limit=8.0)
 9-        runtime.ingest("stale alpha beta", anchors="anchor")
10-        runtime.step(2)
11-        runtime.ingest("fresh", anchors="anchor")
12-        runtime.step(3)
13+        runtime = CIERuntime(capacity_limit=10.0)
14+        for _ in range(4):
15+            runtime.ingest("stale alpha beta", anchors="anchor")
16+            runtime.step()
17+        runtime.reset_session()
18+        runtime.step(6)
19         snapshot = runtime.snapshot_state()
20         self.assertTrue(snapshot["decay_events"])
21         self.assertTrue(any(event["age"] > 0 for event in snapshot["decay_events"]))
22         self.assertTrue(any(event["kind"].startswith("J_") for event in snapshot["decay_events"]))
23-        self.assertLess(snapshot["mu_summary"]["total_activation"], 2.0)
24+        self.assertTrue(any(event["kind"] == "sedimentation_demote" for event in snapshot["decay_events"]))
25+        self.assertEqual(runtime.state.sedimentation["alpha"].stage, "memory")
26 
27     def test_output_mode_reaches_full_degraded_and_minimal(self) -> None:
28         full_runtime = CIERuntime(capacity_limit=10.0)
29@@ -68,8 +71,10 @@ class RuntimeDynamicsTests(unittest.TestCase):
30         self.assertTrue(snapshot["skill_belt_candidates"])
31         self.assertTrue(snapshot["sedimentation_trace"])
32         self.assertTrue(snapshot["feedback_effect"]["applied_tokens"])
33+        self.assertEqual(snapshot["feedback_effect"]["bound_ability_core"], snapshot["bound_ability_core"])
34         self.assertIn("flow", snapshot["skill_belt_candidates"][0])
35         self.assertIn("potential", snapshot["experience_regions"][0])
36+        self.assertIn("nodes", snapshot["experience_regions"][0])
37 
38 
39 if __name__ == "__main__":
A tests/test_sedimentation.py
+66, -0
 1@@ -0,0 +1,66 @@
 2+from __future__ import annotations
 3+
 4+import unittest
 5+
 6+from cie import CIERuntime
 7+
 8+
 9+class RuntimeSedimentationTests(unittest.TestCase):
10+    def test_repeated_activation_creates_explicit_sedimentation_profiles(self) -> None:
11+        runtime = CIERuntime(capacity_limit=10.0)
12+        for _ in range(2):
13+            runtime.ingest("alpha beta alpha", anchors="anchor")
14+            runtime.step()
15+        profile = runtime.state.sedimentation["alpha"]
16+        snapshot = runtime.snapshot_state()
17+        self.assertEqual(profile.stage, "skill_belt")
18+        self.assertGreaterEqual(profile.activation_hits, 2)
19+        self.assertGreater(profile.candidate_score, 1.0)
20+        self.assertTrue(any(event["node"] == "alpha" for event in snapshot["sedimentation_trace"]))
21+
22+    def test_trace_progresses_from_memory_to_ability_core(self) -> None:
23+        runtime = CIERuntime(capacity_limit=10.0)
24+        for _ in range(4):
25+            runtime.ingest("alpha beta alpha", anchors="anchor")
26+            runtime.step()
27+        alpha_events = [
28+            event
29+            for event in runtime.snapshot_state()["sedimentation_trace"]
30+            if event["node"] == "alpha"
31+        ]
32+        self.assertEqual([event["to"] for event in alpha_events], ["experience", "skill_belt", "ability_core"])
33+        self.assertEqual(runtime.state.sedimentation["alpha"].stage, "ability_core")
34+
35+    def test_skill_belt_candidates_and_merge_events_appear_under_repeated_use(self) -> None:
36+        runtime = CIERuntime(capacity_limit=10.0)
37+        for _ in range(4):
38+            runtime.ingest("alpha beta alpha", anchors="anchor")
39+            runtime.step()
40+        snapshot = runtime.snapshot_state()
41+        alpha_candidate = next(item for item in snapshot["skill_belt_candidates"] if item["node"] == "alpha")
42+        self.assertIn(alpha_candidate["stage"], {"skill_belt", "ability_core"})
43+        self.assertGreaterEqual(alpha_candidate["stable_steps"], 2)
44+        self.assertTrue(snapshot["merge_events"])
45+        self.assertTrue(any(event["node"] == "alpha" for event in snapshot["merge_events"]))
46+        self.assertEqual(snapshot["experience_regions"][0]["region"], "alpha")
47+
48+    def test_decay_events_can_demote_sedimentation_state(self) -> None:
49+        runtime = CIERuntime(capacity_limit=10.0)
50+        for _ in range(4):
51+            runtime.ingest("alpha beta alpha", anchors="anchor")
52+            runtime.step()
53+        runtime.reset_session()
54+        runtime.step(6)
55+        snapshot = runtime.snapshot_state()
56+        alpha_events = [
57+            event
58+            for event in snapshot["sedimentation_trace"]
59+            if event["node"] == "alpha" and event["direction"] == "demote"
60+        ]
61+        self.assertTrue(alpha_events)
62+        self.assertTrue(any(event["kind"] == "sedimentation_demote" for event in snapshot["decay_events"]))
63+        self.assertEqual(runtime.state.sedimentation["alpha"].stage, "memory")
64+
65+
66+if __name__ == "__main__":
67+    unittest.main()
M tests/test_smoke.py
+5, -0
 1@@ -37,6 +37,8 @@ class RuntimeSmokeTests(unittest.TestCase):
 2         self.assertIsNotNone(after["bound_ability_core"])
 3         self.assertTrue(after["experience_regions"])
 4         self.assertTrue(after["skill_belt_candidates"])
 5+        self.assertTrue(runtime.state.sedimentation)
 6+        self.assertIn(runtime.state.sedimentation["graph"].stage, {"experience", "skill_belt", "ability_core"})
 7 
 8     def test_snapshot_state_returns_required_keys(self) -> None:
 9         runtime = CIERuntime()
10@@ -76,6 +78,7 @@ class RuntimeSmokeTests(unittest.TestCase):
11         self.assertEqual(after["feedback_effect"]["last_applied_step"], runtime.state.step_index)
12         self.assertTrue(after["feedback_effect"]["applied_tokens"])
13         self.assertGreater(after["feedback_effect"]["phi_delta"], 0.0)
14+        self.assertTrue(after["feedback_effect"]["stage_after"])
15         self.assertGreater(after["phi_summary"]["total_potential"], before["phi_summary"]["total_potential"])
16         self.assertGreater(after["J_summary"]["total_flow"], before["J_summary"]["total_flow"])
17 
18@@ -84,11 +87,13 @@ class RuntimeSmokeTests(unittest.TestCase):
19         runtime.ingest("long term structure", anchors="anchor")
20         runtime.step(2)
21         phi_before = runtime.snapshot_state()["phi_summary"]["node_count"]
22+        sediment_before = dict(runtime.state.sedimentation)
23         runtime.reset_session()
24         snapshot = runtime.snapshot_state()
25         self.assertEqual(snapshot["mu_summary"]["active_count"], 0)
26         self.assertEqual(snapshot["output_mode"], "minimal")
27         self.assertGreaterEqual(snapshot["phi_summary"]["node_count"], phi_before)
28+        self.assertEqual(set(runtime.state.sedimentation), set(sediment_before))
29 
30 
31 if __name__ == "__main__":