CIE-Unified

git clone 

commit
9823c23
parent
3e97255
author
codex@macbookpro
date
2026-03-31 16:47:43 +0800 CST
branch-a: task02 dynamics + feedback + degraded output
8 files changed,  +526, -79
M cie/graph.py
+6, -0
 1@@ -27,5 +27,11 @@ class Graph:
 2     def neighbors(self, node: str) -> Dict[str, float]:
 3         return self.adjacency.get(node, {})
 4 
 5+    def edge_weight(self, source: str, target: str) -> float:
 6+        return self.adjacency.get(source, {}).get(target, 0.0)
 7+
 8+    def weighted_degree(self, node: str) -> float:
 9+        return sum(self.neighbors(node).values())
10+
11     def nodes(self) -> List[str]:
12         return sorted(self.adjacency)
M cie/runtime.py
+288, -61
  1@@ -1,6 +1,5 @@
  2 from __future__ import annotations
  3 
  4-import math
  5 import re
  6 from typing import Any, Dict, Iterable, List
  7 
  8@@ -37,11 +36,11 @@ class CIERuntime:
  9     def __init__(
 10         self,
 11         *,
 12-        activation_retention: float = 0.58,
 13-        activation_spread: float = 0.24,
 14+        activation_retention: float = 0.54,
 15+        activation_spread: float = 0.3,
 16         potential_decay: float = 0.97,
 17-        flow_decay: float = 0.95,
 18-        capacity_limit: float = 4.0,
 19+        flow_decay: float = 0.94,
 20+        capacity_limit: float = 4.5,
 21     ) -> None:
 22         self.state = RuntimeState()
 23         self.activation_retention = activation_retention
 24@@ -82,26 +81,36 @@ class CIERuntime:
 25             output = "minimal: idle"
 26             feedback_tokens = ["idle"]
 27         elif mode == "full":
 28-            output = "full: " + " ".join(active_nodes)
 29             feedback_tokens = active_nodes[:]
 30+            output = "full: " + " -> ".join(feedback_tokens)
 31         elif mode == "degraded":
 32-            output = "degraded: " + " ".join(active_nodes[:2])
 33-            feedback_tokens = active_nodes[:2]
 34+            feedback_tokens = self._degraded_feedback_tokens(active_nodes)
 35+            output = "degraded: " + " / ".join(feedback_tokens)
 36         else:
 37-            output = "minimal: " + active_nodes[0]
 38-            feedback_tokens = active_nodes[:1]
 39+            fallback = self.state.bound_ability_core or active_nodes[0]
 40+            feedback_tokens = [fallback]
 41+            output = "minimal: " + fallback
 42 
 43         feedback_signal = PendingSignal(
 44             source="emit",
 45             tokens=feedback_tokens,
 46-            strength=0.45 if mode == "full" else 0.3,
 47+            context_tokens=active_nodes[:2],
 48+            anchor_tokens=self._top_anchor_nodes(limit=1),
 49+            strength={"full": 0.56, "degraded": 0.38, "minimal": 0.22}[mode],
 50+            metadata={
 51+                "mode": mode,
 52+                "emitted_nodes": list(active_nodes),
 53+                "queued_step": self.state.step_index,
 54+            },
 55         )
 56         self.state.pending_signals.append(feedback_signal)
 57         self.state.last_output = output
 58         self.state.feedback_effect = {
 59             "source": "emit",
 60+            "mode": mode,
 61             "queued_tokens": list(feedback_tokens),
 62             "queued_strength": _round(feedback_signal.strength),
 63+            "confidence_proxy": _round(self.state.confidence_proxy),
 64             "queued_step": self.state.step_index,
 65             "last_applied_step": self.state.feedback_effect.get("last_applied_step"),
 66         }
 67@@ -115,10 +124,12 @@ class CIERuntime:
 68             context_tokens=payload["context_tokens"],
 69             strength=payload["strength"],
 70             polarity=payload["polarity"],
 71+            metadata={"mode": "feedback", "queued_step": self.state.step_index},
 72         )
 73         self.state.pending_signals.append(signal)
 74         self.state.feedback_effect = {
 75             "source": "commit_feedback",
 76+            "mode": "feedback",
 77             "queued_tokens": list(signal.tokens),
 78             "queued_strength": _round(signal.strength),
 79             "polarity": signal.polarity,
 80@@ -160,8 +171,10 @@ class CIERuntime:
 81         self.state.drift_score = 0.0
 82         self.state.anchor_pull = 0.0
 83         self.state.free_capacity = 1.0
 84+        self.state.confidence_proxy = 0.0
 85         self.state.feedback_effect = {
 86             "source": "reset_session",
 87+            "mode": "minimal",
 88             "queued_tokens": [],
 89             "queued_strength": 0.0,
 90             "last_applied_step": self.state.step_index,
 91@@ -169,7 +182,6 @@ class CIERuntime:
 92 
 93     def _advance_once(self) -> None:
 94         self.state.step_index += 1
 95-        self.state.decay_events = []
 96         pending = list(self.state.pending_signals)
 97         self.state.pending_signals.clear()
 98         for signal in pending:
 99@@ -180,46 +192,75 @@ class CIERuntime:
100         self._refresh_observability()
101 
102     def _apply_signal(self, signal: PendingSignal) -> None:
103-        combined = signal.anchor_tokens + signal.context_tokens + signal.tokens
104+        combined = self._ordered_unique(signal.anchor_tokens + signal.context_tokens + signal.tokens)
105         for node in combined:
106             self.state.graph.ensure_node(node)
107             self.state.phi.setdefault(node, 0.0)
108             self.state.mu.setdefault(node, 0.0)
109             self.state.strata.setdefault(node, "memory")
110             self.state.touch_count[node] = self.state.touch_count.get(node, 0) + 1
111+            self.state.node_last_touched[node] = self.state.step_index
112         for anchor in signal.anchor_tokens:
113-            self.state.anchor_nodes[anchor] = self.state.anchor_nodes.get(anchor, 0.0) + 1.0
114+            self.state.anchor_nodes[anchor] = self.state.anchor_nodes.get(anchor, 0.0) + (0.7 * signal.strength)
115+            self.state.phi[anchor] = self.state.phi.get(anchor, 0.0) + (0.12 * signal.strength)
116         self.state.graph.connect_path(combined, weight=max(0.5, signal.strength))
117         for left, right in zip(combined, combined[1:]):
118             key = (left, right)
119-            self.state.J[key] = self.state.J.get(key, 0.0) + (0.35 * signal.strength)
120+            self.state.J[key] = self.state.J.get(key, 0.0) + (0.24 * signal.strength)
121+            self.state.edge_last_touched[key] = self.state.step_index
122         for token in signal.tokens:
123-            activation_gain = 0.9 * signal.strength
124-            potential_gain = 0.28 * signal.strength * signal.polarity
125-            self.state.mu[token] = self.state.mu.get(token, 0.0) + activation_gain
126-            self.state.phi[token] = self.state.phi.get(token, 0.0) + potential_gain
127+            activation_gain = 0.78 * signal.strength
128+            potential_gain = 0.22 * signal.strength
129+            if signal.polarity >= 0:
130+                self.state.mu[token] = self.state.mu.get(token, 0.0) + activation_gain
131+                self.state.phi[token] = self.state.phi.get(token, 0.0) + potential_gain
132+            else:
133+                self.state.mu[token] = max(0.0, self.state.mu.get(token, 0.0) - (0.6 * activation_gain))
134+                self.state.phi[token] = max(0.0, self.state.phi.get(token, 0.0) - (0.4 * potential_gain))
135+                self._record_decay("feedback_suppression", token, activation_gain * 0.6, age=0)
136             self._update_sedimentation(token)
137-            if signal.polarity < 0:
138-                self._record_decay("feedback_suppression", token, activation_gain)
139         if signal.source in {"emit", "feedback"}:
140-            self.state.feedback_effect["last_applied_step"] = self.state.step_index
141+            self._apply_feedback_signal(signal)
142 
143     def _propagate_activation(self) -> None:
144         next_mu: Dict[str, float] = {}
145-        for node, activation in list(self.state.mu.items()):
146+        incoming: Dict[str, float] = {}
147+        current_mu = dict(self.state.mu)
148+        for node, activation in current_mu.items():
149             if activation <= 0.0:
150                 continue
151             retained = activation * self.activation_retention
152             next_mu[node] = next_mu.get(node, 0.0) + retained
153             neighbors = self.state.graph.neighbors(node)
154             if neighbors:
155-                total_weight = sum(neighbors.values()) or 1.0
156+                scored_neighbors = []
157                 for neighbor, weight in neighbors.items():
158-                    spread = activation * self.activation_spread * (weight / total_weight)
159+                    forward = self.state.J.get((node, neighbor), 0.0)
160+                    reverse = self.state.J.get((neighbor, node), 0.0)
161+                    phi_bias = max(self.state.phi.get(neighbor, 0.0), 0.0)
162+                    anchor_bias = 0.2 * self.state.anchor_nodes.get(neighbor, 0.0)
163+                    score = weight + max(0.0, forward - (0.35 * reverse)) + (0.22 * phi_bias) + anchor_bias
164+                    scored_neighbors.append((neighbor, max(0.05, score)))
165+                total_weight = sum(score for _, score in scored_neighbors) or 1.0
166+                spread_budget = activation * self.activation_spread
167+                for neighbor, score in scored_neighbors:
168+                    spread = spread_budget * (score / total_weight)
169                     next_mu[neighbor] = next_mu.get(neighbor, 0.0) + spread
170-                    self.state.J[(node, neighbor)] = self.state.J.get((node, neighbor), 0.0) + spread * 0.1
171-            self.state.phi[node] = self.state.phi.get(node, 0.0) + retained * 0.06
172-        self.state.mu = next_mu
173+                    incoming[neighbor] = incoming.get(neighbor, 0.0) + spread
174+                    self.state.J[(node, neighbor)] = self.state.J.get((node, neighbor), 0.0) + spread * 0.18
175+                    self.state.edge_last_touched[(node, neighbor)] = self.state.step_index
176+            self.state.phi[node] = self.state.phi.get(node, 0.0) + retained * 0.04
177+        for node, gained in incoming.items():
178+            self.state.node_last_touched[node] = self.state.step_index
179+            if gained > 0.12:
180+                self.state.touch_count[node] = self.state.touch_count.get(node, 0) + 1
181+            recurrence = min(self.state.touch_count.get(node, 0), 4)
182+            phi_gain = gained * (0.08 + (0.02 * recurrence))
183+            if node in self.state.anchor_nodes:
184+                phi_gain += 0.03
185+            self.state.phi[node] = self.state.phi.get(node, 0.0) + phi_gain
186+            self._update_sedimentation(node)
187+        self.state.mu = {node: value for node, value in next_mu.items() if value >= 0.02}
188 
189     def _apply_homing(self) -> None:
190         core = self._select_bound_core()
191@@ -227,42 +268,84 @@ class CIERuntime:
192         if not core or not self.state.mu:
193             self.state.anchor_pull = 0.0
194             return
195-        anchor_factor = 0.05 if self.state.anchor_nodes else 0.02
196-        moved = 0.0
197+        anchors = self._top_anchor_nodes(limit=1)
198+        moved_to_anchor = 0.0
199+        moved_to_core = 0.0
200+        core_neighbors = set(self.state.graph.neighbors(core))
201         for node, activation in list(self.state.mu.items()):
202             if node == core or activation <= 0.0:
203                 continue
204-            shift = activation * anchor_factor
205+            near_core = node in core_neighbors
206+            shift_rate = 0.04 if near_core else 0.08
207+            if self.state.strata.get(node, "memory") in {"memory", "experience"}:
208+                shift_rate += 0.03
209+            if anchors:
210+                shift_rate += 0.03
211+            shift = activation * min(0.22, shift_rate)
212             if shift <= 0.0:
213                 continue
214             self.state.mu[node] = max(0.0, activation - shift)
215-            self.state.mu[core] = self.state.mu.get(core, 0.0) + shift
216-            moved += shift
217-        self.state.anchor_pull = moved
218+            to_anchor = shift * 0.35 if anchors else 0.0
219+            to_core = shift - to_anchor
220+            self.state.mu[core] = self.state.mu.get(core, 0.0) + to_core
221+            self.state.J[(node, core)] = self.state.J.get((node, core), 0.0) + (to_core * 0.18)
222+            self.state.edge_last_touched[(node, core)] = self.state.step_index
223+            self.state.node_last_touched[core] = self.state.step_index
224+            moved_to_core += to_core
225+            if anchors and to_anchor > 0.0:
226+                anchor = anchors[0]
227+                self.state.mu[anchor] = self.state.mu.get(anchor, 0.0) + to_anchor
228+                self.state.phi[anchor] = self.state.phi.get(anchor, 0.0) + (to_anchor * 0.08)
229+                self.state.J[(node, anchor)] = self.state.J.get((node, anchor), 0.0) + (to_anchor * 0.14)
230+                self.state.edge_last_touched[(node, anchor)] = self.state.step_index
231+                self.state.node_last_touched[anchor] = self.state.step_index
232+                moved_to_anchor += to_anchor
233+        self.state.phi[core] = self.state.phi.get(core, 0.0) + (moved_to_core * 0.05)
234+        self.state.anchor_pull = moved_to_anchor
235 
236     def _apply_decay(self) -> None:
237         for node, value in list(self.state.phi.items()):
238-            decayed = value * self.potential_decay
239+            age = self.state.step_index - self.state.node_last_touched.get(node, self.state.step_index)
240+            factor = self.potential_decay - min(0.015 * age, 0.1)
241+            if node in self.state.anchor_nodes:
242+                factor += 0.03
243+            factor = max(0.8, min(0.995, factor))
244+            decayed = value * factor
245             if decayed < value - 0.01:
246-                self._record_decay("phi", node, value - decayed)
247-            if abs(decayed) < 0.015:
248+                self._record_decay("phi_decay", node, value - decayed, age=age)
249+            if abs(decayed) < 0.015 and age >= 2:
250                 self.state.phi.pop(node, None)
251+                self._record_decay("phi_prune", node, decayed, age=age)
252                 continue
253             self.state.phi[node] = decayed
254         for node, value in list(self.state.mu.items()):
255-            decayed = value * 0.92
256+            age = self.state.step_index - self.state.node_last_touched.get(node, self.state.step_index)
257+            factor = 0.88 - min(0.04 * age, 0.24)
258+            if node == self.state.bound_ability_core:
259+                factor += 0.05
260+            if node in self.state.anchor_nodes:
261+                factor += 0.03
262+            factor = max(0.52, min(0.96, factor))
263+            decayed = value * factor
264             if decayed < value - 0.01:
265-                self._record_decay("mu", node, value - decayed)
266+                self._record_decay("mu_decay", node, value - decayed, age=age)
267             if decayed < 0.05:
268                 self.state.mu.pop(node, None)
269+                self._record_decay("mu_prune", node, decayed, age=age)
270                 continue
271             self.state.mu[node] = decayed
272         for edge, value in list(self.state.J.items()):
273-            decayed = value * self.flow_decay
274+            age = self.state.step_index - self.state.edge_last_touched.get(edge, self.state.step_index)
275+            factor = self.flow_decay - min(0.03 * age, 0.18)
276+            if self.state.bound_ability_core in edge:
277+                factor += 0.03
278+            factor = max(0.58, min(0.98, factor))
279+            decayed = value * factor
280             if decayed < value - 0.01:
281-                self._record_decay("J", f"{edge[0]}->{edge[1]}", value - decayed)
282-            if decayed < 0.03:
283+                self._record_decay("J_decay", f"{edge[0]}->{edge[1]}", value - decayed, age=age)
284+            if decayed < 0.03 and age >= 2:
285                 self.state.J.pop(edge, None)
286+                self._record_decay("J_prune", f"{edge[0]}->{edge[1]}", decayed, age=age)
287                 continue
288             self.state.J[edge] = decayed
289 
290@@ -272,17 +355,19 @@ class CIERuntime:
291         self.state.drift_score = self._compute_drift_score()
292         total_activation = sum(self.state.mu.values())
293         self.state.free_capacity = max(0.0, 1.0 - min(total_activation / self.capacity_limit, 1.0))
294+        self.state.confidence_proxy = self._confidence_proxy()
295         self.state.output_mode = self._choose_output_mode()
296 
297     def _update_sedimentation(self, node: str) -> None:
298         old_stage = self.state.strata.get(node, "memory")
299         touches = self.state.touch_count.get(node, 0)
300         phi = self.state.phi.get(node, 0.0)
301-        if touches >= 5 or phi >= 1.2:
302+        flow = self._node_flow(node)
303+        if touches >= 6 or phi >= 1.3 or flow >= 1.2:
304             new_stage = "ability_core"
305-        elif touches >= 3 or phi >= 0.8:
306+        elif touches >= 4 or phi >= 0.85 or flow >= 0.7:
307             new_stage = "skill_belt"
308-        elif touches >= 2 or phi >= 0.3:
309+        elif touches >= 2 or phi >= 0.35 or flow >= 0.25:
310             new_stage = "experience"
311         else:
312             new_stage = "memory"
313@@ -293,6 +378,8 @@ class CIERuntime:
314                 "node": node,
315                 "from": old_stage,
316                 "to": new_stage,
317+                "phi": _round(phi),
318+                "flow": _round(flow),
319             }
320             self.state.sedimentation_trace.append(event)
321             self.state.sedimentation_trace = self.state.sedimentation_trace[-12:]
322@@ -302,25 +389,36 @@ class CIERuntime:
323                 )
324                 self.state.merge_events = self.state.merge_events[-8:]
325 
326-    def _record_decay(self, kind: str, target: str, amount: float) -> None:
327+    def _record_decay(self, kind: str, target: str, amount: float, *, age: int) -> None:
328         self.state.decay_events.append(
329             {
330                 "step": self.state.step_index,
331                 "kind": kind,
332                 "target": target,
333                 "amount": _round(amount),
334+                "age": age,
335             }
336         )
337-        self.state.decay_events = self.state.decay_events[-12:]
338+        self.state.decay_events = self.state.decay_events[-20:]
339 
340     def _select_bound_core(self) -> str | None:
341-        candidates = {
342-            node: self.state.phi.get(node, 0.0) + (0.4 * self.state.touch_count.get(node, 0))
343-            for node in self.state.graph.nodes()
344-        }
345-        if not candidates:
346+        nodes = self.state.graph.nodes()
347+        if not nodes:
348             return None
349-        return max(candidates, key=lambda node: (candidates[node], self.state.mu.get(node, 0.0), node))
350+        stage_rank = {stage: index for index, stage in enumerate(STAGE_ORDER)}
351+        return max(
352+            nodes,
353+            key=lambda node: (
354+                stage_rank.get(self.state.strata.get(node, "memory"), 0),
355+                self.state.phi.get(node, 0.0)
356+                + (0.24 * self.state.touch_count.get(node, 0))
357+                + (0.14 * self._node_flow(node))
358+                + (0.08 * self.state.graph.weighted_degree(node))
359+                + (0.12 * self.state.anchor_nodes.get(node, 0.0)),
360+                self.state.mu.get(node, 0.0),
361+                node,
362+            ),
363+        )
364 
365     def _compute_drift_score(self) -> float:
366         active_total = sum(self.state.mu.values())
367@@ -329,19 +427,55 @@ class CIERuntime:
368         core = self.state.bound_ability_core
369         if not core:
370             return 0.0
371-        in_core = self.state.mu.get(core, 0.0)
372+        attached = self.state.mu.get(core, 0.0)
373+        core_neighbors = set(self.state.graph.neighbors(core))
374+        detached_nodes = 0
375+        for node, activation in self.state.mu.items():
376+            if node == core:
377+                continue
378+            if node in core_neighbors:
379+                attached += activation * 0.85
380+            elif node in self.state.anchor_nodes:
381+                attached += activation * 0.6
382+            else:
383+                detached_nodes += 1
384+        support_ratio = min(attached / active_total, 1.0)
385+        frontier_penalty = 0.08 * detached_nodes
386+        anchor_penalty = 0.0
387         if self.state.anchor_nodes:
388-            in_core += sum(self.state.mu.get(anchor, 0.0) for anchor in self.state.anchor_nodes)
389-        spread_penalty = 0.12 * max(0, len(self.state.mu) - 1)
390-        drift = max(0.0, 1.0 - min(in_core / active_total, 1.0) + spread_penalty)
391+            anchor_mass = sum(self.state.mu.get(anchor, 0.0) for anchor in self.state.anchor_nodes)
392+            anchor_penalty = max(0.0, 0.18 - min(anchor_mass / active_total, 0.18))
393+        drift = max(0.0, 1.0 - support_ratio + frontier_penalty + anchor_penalty)
394         return min(drift, 1.0)
395 
396-    def _choose_output_mode(self) -> str:
397+    def _confidence_proxy(self) -> float:
398         if not self.state.mu:
399+            return 0.0
400+        ordered = sorted(self.state.mu.values(), reverse=True)
401+        total_activation = sum(ordered)
402+        top = ordered[0]
403+        second = ordered[1] if len(ordered) > 1 else 0.0
404+        concentration = top / total_activation
405+        separation = max(0.0, top - second) / max(top, 1e-9)
406+        core = self.state.bound_ability_core or self._select_bound_core()
407+        local_flow = self._node_flow(core) if core else top
408+        total_flow = sum(self.state.J.values()) or 1.0
409+        flow_ratio = min(local_flow / total_flow, 1.0)
410+        if self.state.anchor_nodes:
411+            anchor_mass = sum(self.state.mu.get(anchor, 0.0) for anchor in self.state.anchor_nodes)
412+            anchor_ratio = min(anchor_mass / total_activation, 1.0)
413+        else:
414+            anchor_ratio = concentration
415+        return min(1.0, (0.45 * concentration) + (0.25 * separation) + (0.2 * flow_ratio) + (0.1 * anchor_ratio))
416+
417+    def _choose_output_mode(self) -> str:
418+        total_activation = sum(self.state.mu.values())
419+        if total_activation <= 0.0:
420             return "minimal"
421-        if self.state.free_capacity < 0.2 or self.state.drift_score > 0.85:
422+        confidence = self.state.confidence_proxy or self._confidence_proxy()
423+        if total_activation < 0.3 or self.state.free_capacity < 0.15 or confidence < 0.32:
424             return "minimal"
425-        if self.state.free_capacity < 0.55 or self.state.drift_score > 0.45:
426+        if self.state.free_capacity < 0.5 or self.state.drift_score > 0.45 or confidence < 0.55:
427             return "degraded"
428         return "full"
429 
430@@ -384,6 +518,8 @@ class CIERuntime:
431                     "region": node,
432                     "stage": stage,
433                     "activation": _round(self.state.mu.get(node, 0.0)),
434+                    "potential": _round(self.state.phi.get(node, 0.0)),
435+                    "touches": self.state.touch_count.get(node, 0),
436                 }
437             )
438         return sorted(items, key=lambda item: (-item["activation"], item["region"]))[:6]
439@@ -394,16 +530,96 @@ class CIERuntime:
440             stage = self.state.strata.get(node, "memory")
441             if stage not in {"skill_belt", "ability_core"} and self.state.touch_count.get(node, 0) < 2:
442                 continue
443-            score = self.state.phi.get(node, 0.0) + (0.25 * self.state.touch_count.get(node, 0))
444+            score = (
445+                self.state.phi.get(node, 0.0)
446+                + (0.22 * self.state.touch_count.get(node, 0))
447+                + (0.12 * self._node_flow(node))
448+            )
449             items.append(
450                 {
451                     "node": node,
452                     "score": _round(score),
453                     "stage": stage,
454+                    "flow": _round(self._node_flow(node)),
455                 }
456             )
457         return sorted(items, key=lambda item: (-item["score"], item["node"]))[:6]
458 
459+    def _apply_feedback_signal(self, signal: PendingSignal) -> None:
460+        effect = dict(self.state.feedback_effect)
461+        mode = str(signal.metadata.get("mode", "feedback"))
462+        focus_nodes = self._ordered_unique(signal.metadata.get("emitted_nodes", signal.tokens))
463+        if not focus_nodes:
464+            focus_nodes = self._ordered_unique(signal.tokens)
465+        mode_scale = {"full": 1.0, "degraded": 0.72, "minimal": 0.45, "feedback": 0.68}.get(mode, 0.68)
466+        phi_delta = 0.0
467+        mu_delta = 0.0
468+        flow_delta = 0.0
469+        applied_tokens: List[str] = []
470+        for index, node in enumerate(focus_nodes[:4]):
471+            weight = (signal.strength * mode_scale) / (index + 1)
472+            self.state.graph.ensure_node(node)
473+            self.state.phi.setdefault(node, 0.0)
474+            self.state.mu.setdefault(node, 0.0)
475+            if signal.polarity >= 0:
476+                phi_gain = 0.08 * weight
477+                mu_gain = 0.11 * weight
478+                self.state.phi[node] += phi_gain
479+                self.state.mu[node] += mu_gain
480+                phi_delta += phi_gain
481+                mu_delta += mu_gain
482+            else:
483+                phi_loss = min(self.state.phi[node], 0.06 * weight)
484+                mu_loss = min(self.state.mu[node], 0.1 * weight)
485+                self.state.phi[node] -= phi_loss
486+                self.state.mu[node] = max(0.0, self.state.mu[node] - mu_loss)
487+                phi_delta -= phi_loss
488+                mu_delta -= mu_loss
489+            self.state.node_last_touched[node] = self.state.step_index
490+            applied_tokens.append(node)
491+            self._update_sedimentation(node)
492+        for left, right in zip(applied_tokens, applied_tokens[1:]):
493+            gain = 0.09 * signal.strength * mode_scale * signal.polarity
494+            self.state.J[(left, right)] = max(0.0, self.state.J.get((left, right), 0.0) + gain)
495+            self.state.edge_last_touched[(left, right)] = self.state.step_index
496+            flow_delta += gain
497+        effect.update(
498+            {
499+                "source": signal.source,
500+                "mode": mode,
501+                "last_applied_step": self.state.step_index,
502+                "applied_tokens": applied_tokens,
503+                "phi_delta": _round(phi_delta),
504+                "mu_delta": _round(mu_delta),
505+                "flow_delta": _round(flow_delta),
506+            }
507+        )
508+        self.state.feedback_effect = effect
509+
510+    def _degraded_feedback_tokens(self, active_nodes: List[str]) -> List[str]:
511+        focus = []
512+        if self.state.bound_ability_core:
513+            focus.append(self.state.bound_ability_core)
514+        for node in active_nodes:
515+            if node not in focus:
516+                focus.append(node)
517+            if len(focus) >= 2:
518+                break
519+        return focus[:2] or active_nodes[:2]
520+
521+    def _node_flow(self, node: str | None) -> float:
522+        if not node:
523+            return 0.0
524+        total = 0.0
525+        for neighbor in self.state.graph.neighbors(node):
526+            total += self.state.J.get((node, neighbor), 0.0)
527+            total += self.state.J.get((neighbor, node), 0.0)
528+        return total
529+
530+    def _top_anchor_nodes(self, limit: int = 2) -> List[str]:
531+        ordered = sorted(self.state.anchor_nodes.items(), key=lambda item: (-item[1], item[0]))
532+        return [node for node, _ in ordered[:limit]]
533+
534     def _top_scored(self, values: Dict[str, float], limit: int = 5) -> List[Dict[str, Any]]:
535         ordered = sorted(values.items(), key=lambda item: (-item[1], item[0]))
536         return [{"node": node, "value": _round(value)} for node, value in ordered[:limit]]
537@@ -435,6 +651,17 @@ class CIERuntime:
538             "polarity": polarity,
539         }
540 
541+    def _ordered_unique(self, values: Iterable[Any]) -> List[str]:
542+        ordered: List[str] = []
543+        seen = set()
544+        for value in values:
545+            token = str(value)
546+            if not token or token in seen:
547+                continue
548+            seen.add(token)
549+            ordered.append(token)
550+        return ordered
551+
552     def _tokenize(self, payload: Any) -> List[str]:
553         if payload is None:
554             return []
M cie/state.py
+5, -1
 1@@ -1,7 +1,7 @@
 2 from __future__ import annotations
 3 
 4 from dataclasses import dataclass, field
 5-from typing import Dict, List, Tuple
 6+from typing import Any, Dict, List, Tuple
 7 
 8 from .graph import Graph
 9 
10@@ -14,6 +14,7 @@ class PendingSignal:
11     anchor_tokens: List[str] = field(default_factory=list)
12     strength: float = 1.0
13     polarity: int = 1
14+    metadata: Dict[str, Any] = field(default_factory=dict)
15 
16 
17 @dataclass
18@@ -25,6 +26,8 @@ class RuntimeState:
19     anchor_nodes: Dict[str, float] = field(default_factory=dict)
20     strata: Dict[str, str] = field(default_factory=dict)
21     touch_count: Dict[str, int] = field(default_factory=dict)
22+    node_last_touched: Dict[str, int] = field(default_factory=dict)
23+    edge_last_touched: Dict[Tuple[str, str], int] = field(default_factory=dict)
24     pending_signals: List[PendingSignal] = field(default_factory=list)
25     sedimentation_trace: List[dict] = field(default_factory=list)
26     merge_events: List[dict] = field(default_factory=list)
27@@ -38,3 +41,4 @@ class RuntimeState:
28     drift_score: float = 0.0
29     anchor_pull: float = 0.0
30     free_capacity: float = 1.0
31+    confidence_proxy: float = 0.0
M plans/2026-03-31_branch_a_plan.md
+4, -4
 1@@ -13,10 +13,10 @@ Branch A is the pure graph-native minimal runtime line:
 2 
 3 ## Planned Tasks
 4 
 5-1. **Task 01: scaffold + observability**
 6-   Build the repo-native docs, minimal runtime package, and smoke tests with locked interface coverage.
 7-2. **Task 02: minimal dynamics + feedback loop + decay + degraded output**
 8-   Tighten the step loop, make homing/decay more expressive, and harden degraded emission behavior.
 9+1. **Task 01: scaffold + observability** `[completed]`
10+   Repo-native scaffold, locked interface, and smoke coverage are in place.
11+2. **Task 02: minimal dynamics + feedback loop + decay + degraded output** `[completed]`
12+   Runtime dynamics, output feedback, observable decay, and mode degradation are now implemented and validated.
13 3. **Task 03: sedimentation path + skill belt candidates + merge/decay events**
14    Expand the memory-to-experience-to-skill-to-core path and make event traces more meaningful.
15 4. **Task 04: unified validation/reporting against locked spec**
M tasks/2026-03-31_task01_branch_a_round1_scaffold.md
+5, -0
1@@ -138,3 +138,8 @@ The runtime must stay graph-native, keep `(phi, mu, J)` as the canonical state,
2   - homing and decay are real but intentionally heuristic and lightweight
3   - emit feedback is structural placeholder feedback, not yet richer semantic feedback
4   - task switching, stronger degraded-output behavior, and more expressive dynamics remain for the next round
5+
6+## Task 02 Continuation Note
7+
8+- Task 02 branched from commit `3e972559b9ea28fdad8c8eccd8d206afd669836f`.
9+- Continuation branch: `branch-a/task02-dynamics-feedback`
A tasks/2026-03-31_task02_branch_a_dynamics_feedback.md
+120, -0
  1@@ -0,0 +1,120 @@
  2+# Task 02: Branch A Dynamics + Feedback
  3+
  4+## Title
  5+
  6+Task 02: minimal dynamics + feedback loop + decay + degraded output
  7+
  8+## Direct Prompt
  9+
 10+Continue Branch A from commit `3e972559b9ea28fdad8c8eccd8d206afd669836f`, keep the implementation independent from other branches, strengthen the graph-native runtime beyond scaffold level, make output feedback materially affect later state, make decay observable, make degraded output real and testable, and record execution results in-repo.
 11+
 12+## Suggested Branch Name
 13+
 14+`branch-a/task02-dynamics-feedback`
 15+
 16+## Goal
 17+
 18+Implement the smallest inspectable Branch A runtime that has real local graph dynamics across `(phi, mu, J)`, real output-to-input feedback, observable forgetting, and runtime-conditioned `full` / `degraded` / `minimal` output behavior.
 19+
 20+## Background
 21+
 22+This round must continue 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, output-to-input feedback, homing, decay, degraded output, 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 2
 36+- strengthen `step()` so `phi`, `mu`, and `J` evolve through explicit local rules
 37+- make `emit()` queue structured internal feedback that changes later runtime state
 38+- make inactivity decay and forgetting observable through retained event traces
 39+- make `output_mode` depend on real runtime conditions
 40+- improve snapshot observability while keeping the runtime small and stdlib-only
 41+- extend tests for dynamics, feedback, decay, and degraded output
 42+
 43+## Allowed Modifications
 44+
 45+- `/Users/george/code/CIE-Unified/plans/2026-03-31_branch_a_plan.md`
 46+- `/Users/george/code/CIE-Unified/tasks/2026-03-31_task01_branch_a_round1_scaffold.md`
 47+- `/Users/george/code/CIE-Unified/tasks/2026-03-31_task02_branch_a_dynamics_feedback.md`
 48+- `/Users/george/code/CIE-Unified/cie/__init__.py`
 49+- `/Users/george/code/CIE-Unified/cie/graph.py`
 50+- `/Users/george/code/CIE-Unified/cie/state.py`
 51+- `/Users/george/code/CIE-Unified/cie/runtime.py`
 52+- `/Users/george/code/CIE-Unified/tests/__init__.py`
 53+- `/Users/george/code/CIE-Unified/tests/test_smoke.py`
 54+- `/Users/george/code/CIE-Unified/tests/test_dynamics.py`
 55+
 56+## Avoid Modifying
 57+
 58+- `/Users/george/code/CIE-Unified/README.md`
 59+- `/Users/george/code/CIE-Unified/LOCKED_IMPLEMENTATION_SPEC.md`
 60+
 61+## Must Complete
 62+
 63+- mark Task 01 complete and Task 02 complete in the Branch A plan
 64+- create this Task 02 prompt document in-repo
 65+- strengthen runtime dynamics across `phi`, `mu`, and `J`
 66+- make emit-driven feedback materially affect later steps
 67+- make decay/forgetting real and observable
 68+- make output degradation real and testable
 69+- validate with the recommended unittest command
 70+- record execution details and deferred limitations here
 71+
 72+## Acceptance Criteria
 73+
 74+1. `step()` performs real graph-native state evolution across `phi`, `mu`, and `J`.
 75+2. Output-to-input feedback materially affects subsequent runtime state.
 76+3. Decay/forgetting is real and observable.
 77+4. `output_mode` transitions among `full`, `degraded`, and `minimal` using runtime conditions.
 78+5. `bound_ability_core`, `drift_score`, and `anchor_pull` are more meaningful than scaffold placeholders.
 79+6. Tests pass.
 80+
 81+## Evaluation Requirements
 82+
 83+- use only the Python standard library in runtime code
 84+- keep the implementation minimal, explicit, and inspectable
 85+- stay graph-native with `(phi, mu, J)` as the canonical state
 86+- avoid `exact_text_map`
 87+- avoid MoE-style substitution
 88+- avoid latent-vector ontology as the real runtime state
 89+- keep observability stronger than hidden heuristics
 90+
 91+## Recommended Validation Command
 92+
 93+`python3 -m unittest discover -s tests -v`
 94+
 95+## Delivery Requirements
 96+
 97+- commit on `branch-a/task02-dynamics-feedback`
 98+- push the branch to `origin`
 99+- keep the implementation independent from `branch-b` and other later branches
100+- include execution record details for branch/base/backup/files/validation/results/limitations
101+
102+## Execution Record
103+
104+- actual branch name: `branch-a/task02-dynamics-feedback`
105+- base commit: `3e972559b9ea28fdad8c8eccd8d206afd669836f`
106+- backup path used for dirty-worktree handling: `none`
107+- files changed:
108+  - `/Users/george/code/CIE-Unified/plans/2026-03-31_branch_a_plan.md`
109+  - `/Users/george/code/CIE-Unified/tasks/2026-03-31_task01_branch_a_round1_scaffold.md`
110+  - `/Users/george/code/CIE-Unified/tasks/2026-03-31_task02_branch_a_dynamics_feedback.md`
111+  - `/Users/george/code/CIE-Unified/cie/graph.py`
112+  - `/Users/george/code/CIE-Unified/cie/state.py`
113+  - `/Users/george/code/CIE-Unified/cie/runtime.py`
114+  - `/Users/george/code/CIE-Unified/tests/test_smoke.py`
115+  - `/Users/george/code/CIE-Unified/tests/test_dynamics.py`
116+- validation command run: `python3 -m unittest discover -s tests -v`
117+- concise test result summary: `Ran 10 tests; all passed.`
118+- remaining limitations deferred to Task 03:
119+  - sedimentation still uses lightweight stage heuristics rather than a richer path-level memory-to-skill trace
120+  - merge events remain node-level promotions, not broader structural merges or loop detection
121+  - feedback is now real and stateful but still local; it does not yet model richer long-horizon teacher correction
A tests/test_dynamics.py
+76, -0
 1@@ -0,0 +1,76 @@
 2+from __future__ import annotations
 3+
 4+import unittest
 5+
 6+from cie import CIERuntime
 7+
 8+
 9+class RuntimeDynamicsTests(unittest.TestCase):
10+    def test_multistep_evolves_phi_mu_and_j(self) -> None:
11+        runtime = CIERuntime(capacity_limit=8.0)
12+        runtime.ingest("alpha beta alpha", context="gamma", anchors="anchor")
13+        step_one = runtime.step()
14+        step_three = runtime.step(2)
15+        self.assertNotEqual(
16+            step_one["phi_summary"]["total_potential"],
17+            step_three["phi_summary"]["total_potential"],
18+        )
19+        self.assertNotEqual(
20+            step_one["mu_summary"]["total_activation"],
21+            step_three["mu_summary"]["total_activation"],
22+        )
23+        self.assertNotEqual(step_one["J_summary"]["total_flow"], step_three["J_summary"]["total_flow"])
24+        self.assertTrue(step_three["sedimentation_trace"])
25+
26+    def test_decay_events_occur_for_inactive_structures(self) -> None:
27+        runtime = CIERuntime(capacity_limit=8.0)
28+        runtime.ingest("stale alpha beta", anchors="anchor")
29+        runtime.step(2)
30+        runtime.ingest("fresh", anchors="anchor")
31+        runtime.step(3)
32+        snapshot = runtime.snapshot_state()
33+        self.assertTrue(snapshot["decay_events"])
34+        self.assertTrue(any(event["age"] > 0 for event in snapshot["decay_events"]))
35+        self.assertTrue(any(event["kind"].startswith("J_") for event in snapshot["decay_events"]))
36+        self.assertLess(snapshot["mu_summary"]["total_activation"], 2.0)
37+
38+    def test_output_mode_reaches_full_degraded_and_minimal(self) -> None:
39+        full_runtime = CIERuntime(capacity_limit=10.0)
40+        full_runtime.ingest("focus focus focus", anchors="anchor")
41+        full_runtime.step(2)
42+        self.assertEqual(full_runtime.snapshot_state()["output_mode"], "full")
43+        self.assertTrue(full_runtime.emit().startswith("full:"))
44+
45+        degraded_runtime = CIERuntime(capacity_limit=6.0)
46+        degraded_runtime.ingest("alpha beta gamma delta epsilon", anchors="anchor")
47+        degraded_runtime.step(2)
48+        self.assertEqual(degraded_runtime.snapshot_state()["output_mode"], "degraded")
49+        self.assertTrue(degraded_runtime.emit().startswith("degraded:"))
50+
51+        minimal_runtime = CIERuntime(capacity_limit=0.9)
52+        minimal_runtime.ingest("alpha beta gamma delta epsilon", anchors="anchor")
53+        minimal_runtime.step(2)
54+        self.assertEqual(minimal_runtime.snapshot_state()["output_mode"], "minimal")
55+        self.assertTrue(minimal_runtime.emit().startswith("minimal:"))
56+
57+    def test_snapshot_fields_stay_meaningful_after_feedback(self) -> None:
58+        runtime = CIERuntime(capacity_limit=8.0)
59+        runtime.ingest("branch a graph native feedback", context="runtime state", anchors="anchor")
60+        runtime.step(2)
61+        runtime.emit()
62+        snapshot = runtime.step()
63+        self.assertTrue(snapshot["active_region"])
64+        self.assertIsNotNone(snapshot["bound_ability_core"])
65+        self.assertGreater(snapshot["anchor_pull"], 0.0)
66+        self.assertGreaterEqual(snapshot["drift_score"], 0.0)
67+        self.assertLessEqual(snapshot["drift_score"], 1.0)
68+        self.assertTrue(snapshot["experience_regions"])
69+        self.assertTrue(snapshot["skill_belt_candidates"])
70+        self.assertTrue(snapshot["sedimentation_trace"])
71+        self.assertTrue(snapshot["feedback_effect"]["applied_tokens"])
72+        self.assertIn("flow", snapshot["skill_belt_candidates"][0])
73+        self.assertIn("potential", snapshot["experience_regions"][0])
74+
75+
76+if __name__ == "__main__":
77+    unittest.main()
M tests/test_smoke.py
+22, -13
 1@@ -12,6 +12,7 @@ class RuntimeSmokeTests(unittest.TestCase):
 2         self.assertEqual(snapshot["output_mode"], "minimal")
 3         self.assertEqual(snapshot["active_region"], [])
 4         self.assertEqual(snapshot["phi_summary"]["node_count"], 0)
 5+        self.assertEqual(snapshot["feedback_effect"], {})
 6 
 7     def test_locked_interface_exists(self) -> None:
 8         runtime = CIERuntime()
 9@@ -28,11 +29,14 @@ class RuntimeSmokeTests(unittest.TestCase):
10     def test_ingest_and_step_change_state(self) -> None:
11         runtime = CIERuntime()
12         before = runtime.snapshot_state()
13-        runtime.ingest("graph native scaffold", context="task01", anchors="branch-a")
14-        after = runtime.step()
15+        runtime.ingest("graph native dynamics", context="task02 runtime", anchors="branch a")
16+        after = runtime.step(2)
17         self.assertNotEqual(before["phi_summary"]["node_count"], after["phi_summary"]["node_count"])
18         self.assertGreater(after["mu_summary"]["total_activation"], 0.0)
19         self.assertTrue(after["active_region"])
20+        self.assertIsNotNone(after["bound_ability_core"])
21+        self.assertTrue(after["experience_regions"])
22+        self.assertTrue(after["skill_belt_candidates"])
23 
24     def test_snapshot_state_returns_required_keys(self) -> None:
25         runtime = CIERuntime()
26@@ -57,29 +61,34 @@ class RuntimeSmokeTests(unittest.TestCase):
27         }
28         self.assertEqual(set(snapshot), expected)
29 
30-    def test_emit_participates_in_feedback_state(self) -> None:
31-        runtime = CIERuntime()
32+    def test_emit_feedback_changes_later_state(self) -> None:
33+        runtime = CIERuntime(capacity_limit=10.0)
34         runtime.ingest("feedback loop", anchors="anchor")
35-        runtime.step()
36+        runtime.step(2)
37         before = runtime.snapshot_state()
38         output = runtime.emit()
39         queued = runtime.snapshot_state()
40         runtime.step()
41         after = runtime.snapshot_state()
42-        self.assertTrue(output)
43+        self.assertTrue(output.startswith("full:"))
44         self.assertEqual(queued["feedback_effect"]["source"], "emit")
45         self.assertTrue(queued["feedback_effect"]["queued_tokens"])
46+        self.assertEqual(after["feedback_effect"]["last_applied_step"], runtime.state.step_index)
47+        self.assertTrue(after["feedback_effect"]["applied_tokens"])
48+        self.assertGreater(after["feedback_effect"]["phi_delta"], 0.0)
49         self.assertGreater(after["phi_summary"]["total_potential"], before["phi_summary"]["total_potential"])
50+        self.assertGreater(after["J_summary"]["total_flow"], before["J_summary"]["total_flow"])
51 
52-    def test_decay_events_can_occur(self) -> None:
53+    def test_reset_session_preserves_long_term_structure(self) -> None:
54         runtime = CIERuntime()
55-        runtime.ingest("decay path")
56-        runtime.step()
57-        for _ in range(4):
58-            runtime.step()
59+        runtime.ingest("long term structure", anchors="anchor")
60+        runtime.step(2)
61+        phi_before = runtime.snapshot_state()["phi_summary"]["node_count"]
62+        runtime.reset_session()
63         snapshot = runtime.snapshot_state()
64-        self.assertTrue(snapshot["decay_events"])
65-        self.assertLess(snapshot["mu_summary"]["total_activation"], 2.0)
66+        self.assertEqual(snapshot["mu_summary"]["active_count"], 0)
67+        self.assertEqual(snapshot["output_mode"], "minimal")
68+        self.assertGreaterEqual(snapshot["phi_summary"]["node_count"], phi_before)
69 
70 
71 if __name__ == "__main__":