pre-reasoning 2.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,744 @@
1
+ """
2
+ heuristic.py — Nested Decision Engine with Algorithmic Scaling
3
+ =================================================================
4
+
5
+ Upgrades over v1.5 tree builder:
6
+ 1. IMPACT SCORING — each node scored by transitive downstream dependencies
7
+ 2. RESOLUTION SIMULATION — step-by-step: resolve root blocker -> unblock chain -> next
8
+ 3. CROSS-TYPE LINKING — conflicts that block deps, delegations that enable resolution
9
+ 4. CRITICAL PATH — longest chain = minimum steps to goal
10
+ 5. PARALLEL OPPORTUNITIES — what can proceed at each resolution step
11
+ 6. ALGORITHMIC SCALING — O(N^2) for N blocks, handles arbitrary complexity
12
+
13
+ The engine is pure algorithm (0 ML params). Takes L1 adapter blocks, outputs:
14
+ - Nested decision tree with impact scores
15
+ - Optimal resolution sequence
16
+ - Parallel opportunity windows
17
+ - Critical path length
18
+
19
+ Pipeline: Raw Text -> L1 adapter (0p) -> ReasoningV2 (0p) -> v1 (473K) -> LLM narrator
20
+
21
+ Author: Dr. Shannon, Mia Labs
22
+ Date: 2026-03-02
23
+ """
24
+
25
+ from typing import List, Dict, Optional, Set, Tuple
26
+ from collections import defaultdict, deque
27
+ import re
28
+
29
+
30
+ # ── Node ─────────────────────────────────────────────────────────────────────
31
+
32
+ class DecisionNode:
33
+ """A node in the v2 nested decision tree."""
34
+
35
+ def __init__(self, block: Dict, index: int):
36
+ self.block = block
37
+ self.index = index
38
+ self.family = block["family"]
39
+ self.entities = set(
40
+ e.strip() for e in block.get("entities", [])
41
+ if e and e.strip() and e.strip().upper() != "NONE"
42
+ )
43
+ self.roles = block.get("roles", {})
44
+ self.confidence = block.get("confidence", 0.5)
45
+ self.source = block.get("source_clause", "")
46
+
47
+ # Tree structure
48
+ self.children: List['DecisionNode'] = []
49
+ self.parent: Optional['DecisionNode'] = None
50
+
51
+ # v2 scoring
52
+ self.label = "UNKNOWN"
53
+ self.impact_score = 0 # how many nodes depend on this (transitive)
54
+ self.chain_depth = 0 # depth in its chain (0 = leaf)
55
+ self.critical_path = False # on the longest chain?
56
+ self.resolution_step = -1 # which step resolves this (-1 = not yet)
57
+
58
+ # Cross-type links
59
+ self.blocks_chain: Optional['DecisionNode'] = None # conflict that blocks a dep
60
+ self.enables_resolution: Optional['DecisionNode'] = None # delegation that resolves a blocker
61
+ self.mediated_by: Optional['DecisionNode'] = None # conflict mediated by entity in chain
62
+
63
+ # Selective dependency metadata
64
+ self.selective_dep = block.get("selective", None)
65
+
66
+ @property
67
+ def depth(self) -> int:
68
+ d = 0
69
+ n = self
70
+ while n.parent is not None:
71
+ d += 1
72
+ n = n.parent
73
+ return d
74
+
75
+ @property
76
+ def is_root_blocker(self) -> bool:
77
+ return self.label == "ROOT BLOCKER"
78
+
79
+ @property
80
+ def is_critical(self) -> bool:
81
+ return self.label in ("ROOT BLOCKER", "CRITICAL CHAIN", "DEPENDENT-CRITICAL")
82
+
83
+ def __repr__(self):
84
+ ents = sorted(self.entities, key=str.lower)[:3]
85
+ return f"[{self.family}] {', '.join(ents)} ({self.label}, impact={self.impact_score})"
86
+
87
+
88
+ # ── Entity Matching ──────────────────────────────────────────────────────────
89
+
90
+ def entity_match(a: str, b: str) -> bool:
91
+ """
92
+ Check if two entity strings refer to the same entity.
93
+ Uses word-boundary matching to avoid false positives
94
+ (e.g., "Entity_1" should NOT match "Entity_10").
95
+
96
+ Rules:
97
+ 1. Exact match (case-insensitive)
98
+ 2. Space-separated word overlap ("lead attorney" ~ "attorney")
99
+ 3. Substring with word boundary check ("committee" in "curriculum committee")
100
+ """
101
+ a, b = a.lower().strip(), b.lower().strip()
102
+ if not a or not b:
103
+ return False
104
+ # Exact match
105
+ if a == b:
106
+ return True
107
+ # Word overlap on SPACE-split only (not underscore)
108
+ # "lead attorney" ~ "attorney" but NOT "entity_1" ~ "entity_2"
109
+ wa = set(a.split())
110
+ wb = set(b.split())
111
+ if wa & wb:
112
+ return True
113
+ # Substring match ONLY if the shorter string is a complete word in the longer
114
+ # This prevents "entity_1" matching "entity_10"
115
+ shorter, longer = (a, b) if len(a) <= len(b) else (b, a)
116
+ if shorter in longer:
117
+ idx = longer.find(shorter)
118
+ end = idx + len(shorter)
119
+ before_ok = (idx == 0 or not longer[idx-1].isalnum())
120
+ after_ok = (end == len(longer) or not longer[end].isalnum())
121
+ if before_ok and after_ok:
122
+ return True
123
+ return False
124
+
125
+
126
+ def entity_overlap(a: DecisionNode, b: DecisionNode) -> Set[str]:
127
+ """Find shared entities between two nodes."""
128
+ shared = set()
129
+ for ea in a.entities:
130
+ for eb in b.entities:
131
+ if entity_match(ea, eb):
132
+ shared.add(ea)
133
+ return shared
134
+
135
+
136
+ # ── v2 Engine ────────────────────────────────────────────────────────────────
137
+
138
+ class ReasoningEngineV2:
139
+ """
140
+ Nested Decision Engine — algorithmic reasoning at scale.
141
+
142
+ Takes flat L1 adapter blocks, produces:
143
+ 1. Dependency tree with impact scores
144
+ 2. Optimal resolution sequence
145
+ 3. Critical path identification
146
+ 4. Cross-type links (conflict-blocks-chain, delegation-enables-resolution)
147
+ 5. Parallel opportunity windows per resolution step
148
+ """
149
+
150
+ CRITICAL_FAMILIES = {"dependency", "prereq"}
151
+ PARALLEL_FAMILIES = {"conflict", "delegate"}
152
+
153
+ def __init__(self, l1_blocks: List[Dict]):
154
+ self.blocks = l1_blocks
155
+ self.nodes = [DecisionNode(blk, i) for i, blk in enumerate(l1_blocks)]
156
+ self._build()
157
+
158
+ def _build(self):
159
+ """Full build pipeline."""
160
+ self._build_dependency_edges()
161
+ self._build_entity_overlap_edges()
162
+ self._detect_cross_type_links()
163
+ self._detect_cycles()
164
+ self._label_nodes()
165
+ self._compute_impact_scores()
166
+ self._identify_critical_path()
167
+ self._compute_resolution_sequence()
168
+
169
+ # ── Step 1: Dependency edges from roles ───────────────────────────────
170
+
171
+ def _build_dependency_edges(self):
172
+ """Build parent-child edges via blocker/blocked role matching."""
173
+ for i, node_a in enumerate(self.nodes):
174
+ if node_a.family not in self.CRITICAL_FAMILIES:
175
+ continue
176
+ for j, node_b in enumerate(self.nodes):
177
+ if i == j or node_b.family not in self.CRITICAL_FAMILIES:
178
+ continue
179
+
180
+ a_blocker = (
181
+ node_a.roles.get("blocker", "") or
182
+ node_a.roles.get("requirement", "")
183
+ ).lower().strip()
184
+ b_blocked = (
185
+ node_b.roles.get("blocked", "") or
186
+ node_b.roles.get("entity", "")
187
+ ).lower().strip()
188
+
189
+ if not a_blocker or not b_blocked:
190
+ continue
191
+
192
+ if entity_match(a_blocker, b_blocked):
193
+ if node_b.parent is None:
194
+ node_b.parent = node_a
195
+ node_a.children.append(node_b)
196
+
197
+ # ── Step 2: Entity overlap edges (fallback) ──────────────────────────
198
+
199
+ def _build_entity_overlap_edges(self):
200
+ """Broader check for chains not caught by role matching."""
201
+ for i, node_a in enumerate(self.nodes):
202
+ if node_a.family not in self.CRITICAL_FAMILIES:
203
+ continue
204
+ if node_a.parent is not None:
205
+ continue
206
+ for j, node_b in enumerate(self.nodes):
207
+ if i == j or node_b.family not in self.CRITICAL_FAMILIES:
208
+ continue
209
+ if node_b in node_a.children:
210
+ continue
211
+ overlap = entity_overlap(node_a, node_b)
212
+ if overlap and node_a.parent is None and node_b.parent is None:
213
+ if j > i:
214
+ node_b.parent = node_a
215
+ node_a.children.append(node_b)
216
+
217
+ # ── Step 3: Cross-type links ─────────────────────────────────────────
218
+
219
+ def _detect_cross_type_links(self):
220
+ """
221
+ Detect cross-type interactions:
222
+ - Conflict that blocks a dependency chain (conflict entity = dep blocked entity)
223
+ - Delegation that enables resolution (delegate task matches blocker condition)
224
+ - Conflict mediator that appears in a dependency chain
225
+ """
226
+ dep_nodes = [n for n in self.nodes if n.family in self.CRITICAL_FAMILIES]
227
+ conflict_nodes = [n for n in self.nodes if n.family == "conflict"]
228
+ delegate_nodes = [n for n in self.nodes if n.family == "delegate"]
229
+
230
+ # Conflict -> blocks chain
231
+ for conflict in conflict_nodes:
232
+ conflict_ents = conflict.entities
233
+ for dep in dep_nodes:
234
+ blocked = (dep.roles.get("blocked", "") or
235
+ dep.roles.get("entity", "")).lower().strip()
236
+ if blocked:
237
+ for ce in conflict_ents:
238
+ if entity_match(ce, blocked):
239
+ conflict.blocks_chain = dep
240
+ break
241
+
242
+ # Delegation -> enables resolution
243
+ for delegate in delegate_nodes:
244
+ # The delegated task might match a blocker's condition
245
+ delegate_task = delegate.roles.get("task", "").lower().strip()
246
+ if not delegate_task:
247
+ # Try to extract task from source clause
248
+ delegate_task = delegate.source.lower() if delegate.source else ""
249
+
250
+ for dep in dep_nodes:
251
+ blocker = (dep.roles.get("blocker", "") or
252
+ dep.roles.get("requirement", "")).lower().strip()
253
+ condition = dep.roles.get("condition", "").lower().strip()
254
+
255
+ if blocker and delegate_task:
256
+ # Check if delegation task relates to resolving the blocker
257
+ if entity_match(delegate_task, blocker) or entity_match(delegate_task, condition):
258
+ delegate.enables_resolution = dep
259
+ break
260
+
261
+ # Also check if delegator/delegate entity appears in the dep chain
262
+ for de in delegate.entities:
263
+ if entity_match(de, blocker):
264
+ delegate.enables_resolution = dep
265
+ break
266
+
267
+ # Conflict mediator in chain
268
+ for conflict in conflict_nodes:
269
+ mediator = conflict.roles.get("mediator", "").lower().strip()
270
+ if not mediator:
271
+ continue
272
+ for dep in dep_nodes:
273
+ for de in dep.entities:
274
+ if entity_match(mediator, de):
275
+ conflict.mediated_by = dep
276
+ break
277
+
278
+ # ── Step 3b: Cycle detection (Tarjan's SCC) ─────────────────────────
279
+
280
+ def _detect_cycles(self):
281
+ """
282
+ Detect circular dependencies using Tarjan's SCC algorithm.
283
+ Flags nodes in cycles of size >= 2 as CIRCULAR_DEPENDENCY.
284
+ Stores cycles in self._cycles for format_for_narrator().
285
+ """
286
+ index_counter = [0]
287
+ stack = []
288
+ on_stack = set()
289
+ indices = {}
290
+ lowlinks = {}
291
+ sccs = []
292
+
293
+ # Build adjacency: parent -> children (dependency direction)
294
+ # Also include cross-type links as edges
295
+ adj = defaultdict(list)
296
+ for node in self.nodes:
297
+ for child in node.children:
298
+ adj[node.index].append(child.index)
299
+ if node.blocks_chain is not None:
300
+ adj[node.index].append(node.blocks_chain.index)
301
+ if node.enables_resolution is not None:
302
+ adj[node.index].append(node.enables_resolution.index)
303
+
304
+ def strongconnect(v):
305
+ indices[v] = index_counter[0]
306
+ lowlinks[v] = index_counter[0]
307
+ index_counter[0] += 1
308
+ stack.append(v)
309
+ on_stack.add(v)
310
+
311
+ for w in adj.get(v, []):
312
+ if w not in indices:
313
+ strongconnect(w)
314
+ lowlinks[v] = min(lowlinks[v], lowlinks[w])
315
+ elif w in on_stack:
316
+ lowlinks[v] = min(lowlinks[v], indices[w])
317
+
318
+ if lowlinks[v] == indices[v]:
319
+ scc = []
320
+ while True:
321
+ w = stack.pop()
322
+ on_stack.discard(w)
323
+ scc.append(w)
324
+ if w == v:
325
+ break
326
+ if len(scc) >= 2:
327
+ sccs.append(scc)
328
+
329
+ for node in self.nodes:
330
+ if node.index not in indices:
331
+ strongconnect(node.index)
332
+
333
+ self._cycles = sccs
334
+
335
+ # Label nodes in cycles
336
+ for cycle in sccs:
337
+ for idx in cycle:
338
+ self.nodes[idx].label = "CIRCULAR_DEPENDENCY"
339
+
340
+ # ── Step 4: Label nodes ──────────────────────────────────────────────
341
+
342
+ def _label_nodes(self):
343
+ """Label all nodes: ROOT BLOCKER, CRITICAL CHAIN, DEPENDENT-CRITICAL, PARALLEL TRACK.
344
+ Skips nodes already labeled CIRCULAR_DEPENDENCY by _detect_cycles()."""
345
+ roots = [n for n in self.nodes if n.parent is None]
346
+
347
+ for root in roots:
348
+ if root.label == "CIRCULAR_DEPENDENCY":
349
+ continue # already labeled by cycle detection
350
+ if root.family in self.CRITICAL_FAMILIES:
351
+ self._label_chain(root, is_root_chain=True)
352
+ else:
353
+ root.label = "PARALLEL TRACK"
354
+ # Check if this parallel node blocks a chain (cross-type)
355
+ if root.blocks_chain is not None:
356
+ root.label = "BLOCKING PARALLEL"
357
+
358
+ def _label_chain(self, node: DecisionNode, is_root_chain: bool):
359
+ """Recursively label a chain. Skips CIRCULAR_DEPENDENCY nodes."""
360
+ if node.label == "CIRCULAR_DEPENDENCY":
361
+ return
362
+ if not node.children:
363
+ node.label = "ROOT BLOCKER" if is_root_chain else "DEPENDENT-CRITICAL"
364
+ elif node.parent is None:
365
+ node.label = "CRITICAL CHAIN"
366
+ else:
367
+ node.label = "DEPENDENT-CRITICAL"
368
+
369
+ for child in node.children:
370
+ self._label_chain(child, is_root_chain)
371
+
372
+ # ── Step 5: Impact scores ────────────────────────────────────────────
373
+
374
+ def _compute_impact_scores(self):
375
+ """
376
+ Compute transitive impact: how many nodes depend on this one?
377
+ Higher impact = resolving this unblocks more work.
378
+ Includes cross-type impacts (conflict blocking a chain).
379
+ """
380
+ for node in self.nodes:
381
+ node.impact_score = self._count_downstream(node)
382
+
383
+ def _count_downstream(self, node: DecisionNode, visited: Set[int] = None) -> int:
384
+ """Count all nodes transitively upstream (depending on this node)."""
385
+ if visited is None:
386
+ visited = set()
387
+ if node.index in visited:
388
+ return 0
389
+ visited.add(node.index)
390
+
391
+ count = 0
392
+ # Direct parent depends on this node
393
+ if node.parent is not None and node.parent.index not in visited:
394
+ count += 1 + self._count_downstream(node.parent, visited)
395
+
396
+ # Cross-type: if this node enables resolution of a dep, that dep's chain benefits
397
+ if node.enables_resolution is not None:
398
+ target = node.enables_resolution
399
+ if target.index not in visited:
400
+ count += 1 + self._count_downstream(target, visited)
401
+
402
+ return count
403
+
404
+ # ── Step 6: Critical path ────────────────────────────────────────────
405
+
406
+ def _identify_critical_path(self):
407
+ """
408
+ Find the longest chain (critical path).
409
+ Critical path = minimum number of sequential steps to reach goal.
410
+ """
411
+ roots = [n for n in self.nodes if n.parent is None and n.is_critical]
412
+ if not roots:
413
+ return
414
+
415
+ max_depth = 0
416
+ deepest_leaf = None
417
+
418
+ for root in roots:
419
+ leaf, depth = self._find_deepest_leaf(root)
420
+ if depth > max_depth:
421
+ max_depth = depth
422
+ deepest_leaf = leaf
423
+
424
+ # Mark critical path from deepest leaf up to root
425
+ if deepest_leaf:
426
+ node = deepest_leaf
427
+ while node is not None:
428
+ node.critical_path = True
429
+ node.chain_depth = node.depth
430
+ node = node.parent
431
+
432
+ def _find_deepest_leaf(self, node: DecisionNode, depth: int = 0) -> Tuple[DecisionNode, int]:
433
+ """Find the deepest leaf in a subtree."""
434
+ if not node.children:
435
+ return node, depth
436
+ best_leaf, best_depth = node, depth
437
+ for child in node.children:
438
+ leaf, d = self._find_deepest_leaf(child, depth + 1)
439
+ if d > best_depth:
440
+ best_leaf, best_depth = leaf, d
441
+ return best_leaf, best_depth
442
+
443
+ # ── Step 7: Resolution sequence ──────────────────────────────────────
444
+
445
+ def _compute_resolution_sequence(self):
446
+ """
447
+ Compute optimal resolution order using topological sort.
448
+ Resolve ROOT BLOCKERs first (sorted by impact score, highest first).
449
+ Then DEPENDENT-CRITICAL nodes get unblocked automatically.
450
+ Parallel tracks can proceed at any time.
451
+ """
452
+ # Gather root blockers sorted by impact (highest first)
453
+ root_blockers = sorted(
454
+ [n for n in self.nodes if n.label == "ROOT BLOCKER"],
455
+ key=lambda n: (-n.impact_score, n.index)
456
+ )
457
+
458
+ step = 1
459
+ resolved = set()
460
+
461
+ # Phase 1: Resolve root blockers
462
+ for rb in root_blockers:
463
+ rb.resolution_step = step
464
+ resolved.add(rb.index)
465
+ step += 1
466
+
467
+ # Phase 2: Propagate resolution up chains (BFS from resolved nodes)
468
+ queue = deque(root_blockers)
469
+ while queue:
470
+ node = queue.popleft()
471
+ if node.parent is not None and node.parent.index not in resolved:
472
+ parent = node.parent
473
+ # Check if ALL children of parent are resolved
474
+ all_children_resolved = all(c.index in resolved for c in parent.children)
475
+ if all_children_resolved:
476
+ parent.resolution_step = step
477
+ resolved.add(parent.index)
478
+ step += 1
479
+ queue.append(parent)
480
+
481
+ # Phase 3: Parallel tracks get step 0 (can proceed anytime)
482
+ for node in self.nodes:
483
+ if node.label in ("PARALLEL TRACK", "BLOCKING PARALLEL"):
484
+ node.resolution_step = 0
485
+
486
+ # ── Public API ───────────────────────────────────────────────────────
487
+
488
+ @property
489
+ def root_blockers(self) -> List[DecisionNode]:
490
+ return sorted(
491
+ [n for n in self.nodes if n.label == "ROOT BLOCKER"],
492
+ key=lambda n: (-n.impact_score, n.index)
493
+ )
494
+
495
+ @property
496
+ def critical_chain(self) -> List[DecisionNode]:
497
+ return [n for n in self.nodes if n.is_critical]
498
+
499
+ @property
500
+ def critical_path_length(self) -> int:
501
+ return max((n.chain_depth for n in self.nodes if n.critical_path), default=0)
502
+
503
+ @property
504
+ def parallel_tracks(self) -> List[DecisionNode]:
505
+ return [n for n in self.nodes if n.label in ("PARALLEL TRACK", "BLOCKING PARALLEL")]
506
+
507
+ @property
508
+ def resolution_steps(self) -> List[Tuple[int, List[DecisionNode]]]:
509
+ """Get resolution sequence grouped by step number."""
510
+ by_step = defaultdict(list)
511
+ for n in self.nodes:
512
+ if n.resolution_step >= 0:
513
+ by_step[n.resolution_step].append(n)
514
+ return sorted(by_step.items())
515
+
516
+ @property
517
+ def circular_dependencies(self) -> List[List[DecisionNode]]:
518
+ """Get all detected circular dependency cycles."""
519
+ cycles = getattr(self, '_cycles', [])
520
+ return [[self.nodes[idx] for idx in cycle] for cycle in cycles]
521
+
522
+ @property
523
+ def cross_type_links(self) -> List[Dict]:
524
+ """Get all detected cross-type interactions."""
525
+ links = []
526
+ for n in self.nodes:
527
+ if n.blocks_chain is not None:
528
+ links.append({
529
+ "type": "conflict_blocks_chain",
530
+ "source": n,
531
+ "target": n.blocks_chain,
532
+ })
533
+ if n.enables_resolution is not None:
534
+ links.append({
535
+ "type": "delegation_enables_resolution",
536
+ "source": n,
537
+ "target": n.enables_resolution,
538
+ })
539
+ if n.mediated_by is not None:
540
+ links.append({
541
+ "type": "mediator_in_chain",
542
+ "source": n,
543
+ "target": n.mediated_by,
544
+ })
545
+ return links
546
+
547
+ # ── Formatting ───────────────────────────────────────────────────────
548
+
549
+ def format_tree(self) -> str:
550
+ """Format the full decision tree as readable text."""
551
+ roots = [n for n in self.nodes if n.parent is None]
552
+ lines = []
553
+
554
+ def _fmt(node: DecisionNode, prefix: str, is_last: bool):
555
+ connector = "+-- "
556
+ ents = sorted(node.entities, key=str.lower)[:3]
557
+ ent_str = ", ".join(ents)
558
+ cp = " *CP*" if node.critical_path else ""
559
+ impact = f" impact={node.impact_score}" if node.impact_score > 0 else ""
560
+ step = f" step={node.resolution_step}" if node.resolution_step > 0 else ""
561
+ line = f"{prefix}{connector}[{node.family}] {ent_str} [{node.label}]{cp}{impact}{step}"
562
+
563
+ src = node.block.get("source_clause", "")
564
+ if src:
565
+ line += f"\n{prefix} '{src[:60]}'"
566
+
567
+ lines.append(line)
568
+
569
+ child_prefix = prefix + ("| " if not is_last else " ")
570
+ for ci, child in enumerate(node.children):
571
+ _fmt(child, child_prefix, ci == len(node.children) - 1)
572
+
573
+ for ri, root in enumerate(roots):
574
+ _fmt(root, "", ri == len(roots) - 1)
575
+
576
+ return "\n".join(lines)
577
+
578
+ def format_summary(self) -> str:
579
+ """Format concise reasoning summary."""
580
+ lines = []
581
+ lines.append("REASONING ENGINE v2 SUMMARY:")
582
+ lines.append(f" Total blocks: {len(self.nodes)}")
583
+ lines.append(f" Critical path length: {self.critical_path_length} steps")
584
+ lines.append(f" Root blockers: {len(self.root_blockers)}")
585
+ lines.append(f" Parallel tracks: {len(self.parallel_tracks)}")
586
+ cycles = self.circular_dependencies
587
+ if cycles:
588
+ lines.append(f" Circular dependencies: {len(cycles)} cycle(s)")
589
+
590
+ # Cross-type links
591
+ ct = self.cross_type_links
592
+ if ct:
593
+ lines.append(f" Cross-type links: {len(ct)}")
594
+ for link in ct:
595
+ src_ents = sorted(link['source'].entities, key=str.lower)[:2]
596
+ tgt_ents = sorted(link['target'].entities, key=str.lower)[:2]
597
+ lines.append(
598
+ f" {link['type']}: "
599
+ f"{', '.join(src_ents)} -> "
600
+ f"{', '.join(tgt_ents)}"
601
+ )
602
+
603
+ # Root blockers with impact
604
+ if self.root_blockers:
605
+ lines.append("")
606
+ lines.append(" ROOT BLOCKERS (resolve FIRST, sorted by impact):")
607
+ for rb in self.root_blockers:
608
+ ents = sorted(rb.entities, key=str.lower)[:3]
609
+ lines.append(
610
+ f" [{rb.index+1}] [{rb.family}] "
611
+ f"{', '.join(ents)} "
612
+ f"(impact={rb.impact_score})"
613
+ )
614
+ if rb.source:
615
+ lines.append(f" '{rb.source[:70]}'")
616
+
617
+ # Resolution sequence
618
+ steps = self.resolution_steps
619
+ if steps:
620
+ lines.append("")
621
+ lines.append(" OPTIMAL RESOLUTION SEQUENCE:")
622
+ for step_num, step_nodes in steps:
623
+ if step_num == 0:
624
+ continue # parallel tracks
625
+ node_strs = []
626
+ for n in step_nodes:
627
+ ents = sorted(n.entities, key=str.lower)[:2]
628
+ node_strs.append(f"[{n.index+1}] {', '.join(ents)}")
629
+ lines.append(f" Step {step_num}: {' + '.join(node_strs)}")
630
+
631
+ # Parallel opportunities
632
+ parallel = self.parallel_tracks
633
+ if parallel:
634
+ lines.append("")
635
+ lines.append(" PARALLEL (proceed independently at any time):")
636
+ for p in parallel:
637
+ ents = sorted(p.entities, key=str.lower)[:3]
638
+ extra = ""
639
+ if p.blocks_chain:
640
+ extra = " [!BLOCKS CHAIN]"
641
+ if p.enables_resolution:
642
+ extra = " [ENABLES RESOLUTION]"
643
+ lines.append(
644
+ f" [{p.index+1}] [{p.family}] "
645
+ f"{', '.join(ents)}{extra}"
646
+ )
647
+
648
+ return "\n".join(lines)
649
+
650
+ def format_for_narrator(self) -> str:
651
+ """
652
+ Format specifically for LLM narrator consumption.
653
+ Structured, concise, with clear labels for grounding.
654
+ """
655
+ lines = []
656
+
657
+ # Framing header — tells the model what the trace IS and how to use it
658
+ lines.append("--- STRUCTURAL TRACE ---")
659
+ lines.append("This is a map of the situation - not a perfect map, but a grounding map.")
660
+ lines.append("It gives you the opportunity to see the whole picture before committing to tokens.")
661
+ lines.append("Now, create the solution on your own using this map.")
662
+ lines.append("")
663
+
664
+ # Root blockers
665
+ lines.append("ROOT BLOCKERS (must resolve FIRST):")
666
+ for rb in self.root_blockers:
667
+ ents = sorted(rb.entities, key=str.lower)[:3]
668
+ src = rb.source[:80] if rb.source else ""
669
+ lines.append(f" Block [{rb.index+1}]: {', '.join(ents)}")
670
+ lines.append(f" Impact: unblocks {rb.impact_score} downstream steps")
671
+ if src:
672
+ lines.append(f" Evidence: '{src}'")
673
+
674
+ # Resolution sequence
675
+ lines.append("")
676
+ lines.append("UNLOCK SEQUENCE (optimal order):")
677
+ for step_num, step_nodes in self.resolution_steps:
678
+ if step_num == 0:
679
+ continue
680
+ for n in step_nodes:
681
+ ents = sorted(n.entities, key=str.lower)[:2]
682
+ action = "Resolve" if n.is_root_blocker else "Unblocked"
683
+ lines.append(
684
+ f" Step {step_num}: {action} [{n.index+1}] "
685
+ f"{', '.join(ents)}"
686
+ )
687
+
688
+ # Parallel
689
+ lines.append("")
690
+ lines.append("PARALLEL WORK (proceed now, independent):")
691
+ for p in self.parallel_tracks:
692
+ ents = sorted(p.entities, key=str.lower)[:3]
693
+ lines.append(f" Block [{p.index+1}]: [{p.family}] {', '.join(ents)}")
694
+
695
+ # Cross-type warnings
696
+ ct = self.cross_type_links
697
+ if ct:
698
+ lines.append("")
699
+ lines.append("CROSS-TYPE INTERACTIONS:")
700
+ for link in ct:
701
+ src_ents = sorted(link['source'].entities, key=str.lower)[:2]
702
+ tgt_ents = sorted(link['target'].entities, key=str.lower)[:2]
703
+ if link['type'] == 'conflict_blocks_chain':
704
+ lines.append(
705
+ f" WARNING: Conflict [{link['source'].index+1}] "
706
+ f"({', '.join(src_ents)}) "
707
+ f"may block chain [{link['target'].index+1}]"
708
+ )
709
+ elif link['type'] == 'delegation_enables_resolution':
710
+ lines.append(
711
+ f" OPPORTUNITY: Delegation [{link['source'].index+1}] "
712
+ f"({', '.join(src_ents)}) "
713
+ f"can help resolve [{link['target'].index+1}]"
714
+ )
715
+
716
+ # Circular dependencies
717
+ cycles = self.circular_dependencies
718
+ if cycles:
719
+ lines.append("")
720
+ lines.append("CIRCULAR DEPENDENCIES (cannot resolve sequentially):")
721
+ for ci, cycle in enumerate(cycles):
722
+ ent_parts = []
723
+ for node in cycle:
724
+ ents = sorted(node.entities, key=str.lower)[:2]
725
+ ent_parts.append(f"[{node.index+1}] {', '.join(ents)}")
726
+ lines.append(f" Cycle {ci+1}: {' <-> '.join(ent_parts)}")
727
+ lines.append(f" These depend on each other — use alternative resolution strategy.")
728
+
729
+ # Selective dependencies
730
+ selective_nodes = [n for n in self.nodes if n.selective_dep]
731
+ if selective_nodes:
732
+ lines.append("")
733
+ lines.append("SELECTIVE DEPENDENCIES (not all members are active):")
734
+ for n in selective_nodes:
735
+ sd = n.selective_dep
736
+ ents = sorted(n.entities, key=str.lower)[:3]
737
+ lines.append(f" Block [{n.index+1}]: {', '.join(ents)}")
738
+ lines.append(f" ONLY {sd.get('active_pct', '?')}% of {sd.get('subset_of', 'group')} are active")
739
+ if sd.get("distinguisher"):
740
+ lines.append(f" Distinguisher: {sd['distinguisher']}")
741
+ if sd.get("description"):
742
+ lines.append(f" Detail: {sd['description']}")
743
+
744
+ return "\n".join(lines)