- 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
+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)
+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 []
+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
+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**
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`
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
+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()
+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__":