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.
- pre_reasoning/__init__.py +61 -0
- pre_reasoning/checkpoints/__init__.py +1 -0
- pre_reasoning/checkpoints/pre-reasoning-3m-v2.5.safetensors +0 -0
- pre_reasoning/cli.py +9 -0
- pre_reasoning/heuristic.py +744 -0
- pre_reasoning/inference.py +1326 -0
- pre_reasoning/pre_reasoning_v2_5.py +630 -0
- pre_reasoning-2.5.0.dist-info/METADATA +139 -0
- pre_reasoning-2.5.0.dist-info/RECORD +13 -0
- pre_reasoning-2.5.0.dist-info/WHEEL +5 -0
- pre_reasoning-2.5.0.dist-info/entry_points.txt +2 -0
- pre_reasoning-2.5.0.dist-info/licenses/LICENSE +21 -0
- pre_reasoning-2.5.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|