crprotocol 2.0.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.
Files changed (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,401 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Meta-learning — ORC, ICML, Reasoning Template Library (§19).
4
+
5
+ Three mechanisms:
6
+ 1. Orchestrated Reasoning Chains (ORC): decompose complex reasoning into micro-steps
7
+ 2. In-Context Meta-Learning (ICML): reasoning scaffolds + few-shot examples
8
+ 3. Reasoning Template Library (RTL): store/retrieve successful reasoning traces
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ import uuid
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Data types
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ @dataclass
25
+ class ReasoningStep:
26
+ """Single step in a reasoning chain."""
27
+
28
+ step_description: str = ""
29
+ system_prompt_template: str = ""
30
+ expected_output_format: str = ""
31
+ scaffold_level: int = 0 # 0-3
32
+
33
+
34
+ @dataclass
35
+ class ReasoningTrace:
36
+ """Complete reasoning trace for RTL storage."""
37
+
38
+ trace_id: str = field(default_factory=lambda: str(uuid.uuid4()))
39
+ task_type: str = ""
40
+ task_summary: str = ""
41
+ steps: list[ReasoningStep] = field(default_factory=list)
42
+ model_class: str = "" # "0.5B-1B" | "2B-7B" | "7B+"
43
+ quality_score: float = 0.0
44
+ created_at: float = field(default_factory=time.time)
45
+ usage_count: int = 0
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ return {
49
+ "trace_id": self.trace_id,
50
+ "task_type": self.task_type,
51
+ "task_summary": self.task_summary,
52
+ "steps": [
53
+ {
54
+ "step_description": s.step_description,
55
+ "system_prompt_template": s.system_prompt_template,
56
+ "expected_output_format": s.expected_output_format,
57
+ "scaffold_level": s.scaffold_level,
58
+ }
59
+ for s in self.steps
60
+ ],
61
+ "model_class": self.model_class,
62
+ "quality_score": self.quality_score,
63
+ "created_at": self.created_at,
64
+ "usage_count": self.usage_count,
65
+ }
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: dict[str, Any]) -> ReasoningTrace:
69
+ steps = [
70
+ ReasoningStep(
71
+ step_description=s.get("step_description", ""),
72
+ system_prompt_template=s.get("system_prompt_template", ""),
73
+ expected_output_format=s.get("expected_output_format", ""),
74
+ scaffold_level=s.get("scaffold_level", 0),
75
+ )
76
+ for s in data.get("steps", [])
77
+ ]
78
+ return cls(
79
+ trace_id=data.get("trace_id", str(uuid.uuid4())),
80
+ task_type=data.get("task_type", ""),
81
+ task_summary=data.get("task_summary", ""),
82
+ steps=steps,
83
+ model_class=data.get("model_class", ""),
84
+ quality_score=data.get("quality_score", 0.0),
85
+ created_at=data.get("created_at", 0.0),
86
+ usage_count=data.get("usage_count", 0),
87
+ )
88
+
89
+
90
+ @dataclass
91
+ class ORCResult:
92
+ """Result of orchestrated reasoning chain."""
93
+
94
+ steps_completed: int = 0
95
+ steps_total: int = 0
96
+ final_output: str = ""
97
+ step_outputs: list[str] = field(default_factory=list)
98
+ quality_score: float = 0.0
99
+ trace: ReasoningTrace | None = None
100
+
101
+
102
+ @dataclass
103
+ class MetaLearningConfig:
104
+ """Configuration for meta-learning features."""
105
+
106
+ enabled: bool = True
107
+ orc_enabled: bool = True
108
+ orc_max_steps: int = 10
109
+ orc_min_model_capability: int = 1
110
+ icml_enabled: bool = True
111
+ icml_max_examples: int = 3
112
+ rtl_enabled: bool = True
113
+ rtl_min_quality_for_storage: float = 0.7
114
+ scaffold_level: str = "auto" # "auto" | "none" | "light" | "heavy"
115
+ curation_interval: int = 5
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # MetaLearningEngine
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ class MetaLearningEngine:
124
+ """ORC + ICML + RTL meta-learning capabilities."""
125
+
126
+ def __init__(
127
+ self,
128
+ dispatch_fn: Callable[[str, str], tuple[str, Any]] | None = None,
129
+ model_capability: int = 1,
130
+ config: MetaLearningConfig | None = None,
131
+ ) -> None:
132
+ self._dispatch_fn = dispatch_fn
133
+ self._model_capability = model_capability
134
+ self.config = config or MetaLearningConfig()
135
+ self._trace_library: list[ReasoningTrace] = []
136
+
137
+ # ------------------------------------------------------------------
138
+ # Mechanism 1: Orchestrated Reasoning Chains (ORC)
139
+ # ------------------------------------------------------------------
140
+
141
+ def should_use_orc(
142
+ self,
143
+ task_complexity: int = 3,
144
+ resource_pressure: str = "NONE",
145
+ probe_quality: float = 0.0,
146
+ ) -> bool:
147
+ """Gate check for ORC activation.
148
+
149
+ Gate 1: resource_pressure >= HIGH → False
150
+ Gate 2: model_capability >= task_complexity → False
151
+ Gate 3: probe_quality >= 0.7 → False (ORC unnecessary)
152
+ """
153
+ if not self.config.orc_enabled:
154
+ return False
155
+ if resource_pressure in ("HIGH", "CRITICAL"):
156
+ return False
157
+ if self._model_capability >= task_complexity:
158
+ return False
159
+ return probe_quality < 0.7
160
+
161
+ def orchestrated_reasoning(
162
+ self,
163
+ task_intent: str,
164
+ task_complexity: int = 3,
165
+ resource_pressure: str = "NONE",
166
+ ) -> ORCResult:
167
+ """Decompose and execute orchestrated reasoning chain."""
168
+ # Determine max steps based on resource pressure
169
+ max_steps = self.config.orc_max_steps
170
+ if resource_pressure == "MODERATE":
171
+ max_steps = min(max_steps, 5)
172
+ elif resource_pressure in ("HIGH", "CRITICAL"):
173
+ max_steps = min(max_steps, 3)
174
+
175
+ steps = self._decompose_reasoning(task_intent, max_steps)
176
+ step_outputs: list[str] = []
177
+
178
+ for step in steps:
179
+ if not self._dispatch_fn:
180
+ step_outputs.append(f"[no dispatch] {step.step_description}")
181
+ continue
182
+
183
+ try:
184
+ prompt = step.system_prompt_template or step.step_description
185
+ output, _ = self._dispatch_fn(prompt, "")
186
+ step_outputs.append(output)
187
+ except Exception:
188
+ # Retry with more scaffolding
189
+ scaffolded_prompt = (
190
+ f"Step-by-step, {step.step_description}\n"
191
+ f"Expected format: {step.expected_output_format}"
192
+ )
193
+ try:
194
+ output, _ = self._dispatch_fn(scaffolded_prompt, "")
195
+ step_outputs.append(output)
196
+ except Exception:
197
+ step_outputs.append(f"[failed] {step.step_description}")
198
+
199
+ # Synthesize
200
+ final = self._synthesize_chain(step_outputs, task_intent)
201
+
202
+ trace = ReasoningTrace(
203
+ task_type="orc",
204
+ task_summary=task_intent[:200],
205
+ steps=steps,
206
+ model_class=self._model_class_str(),
207
+ )
208
+
209
+ return ORCResult(
210
+ steps_completed=len(step_outputs),
211
+ steps_total=len(steps),
212
+ final_output=final,
213
+ step_outputs=step_outputs,
214
+ trace=trace,
215
+ )
216
+
217
+ def _decompose_reasoning(
218
+ self, task_intent: str, max_steps: int,
219
+ ) -> list[ReasoningStep]:
220
+ """Decompose task into reasoning steps."""
221
+ if self._dispatch_fn:
222
+ try:
223
+ prompt = (
224
+ f"Decompose this task into {max_steps} or fewer reasoning steps. "
225
+ f"For each step, give a one-line description.\n\n"
226
+ f"Task: {task_intent}"
227
+ )
228
+ output, _ = self._dispatch_fn(prompt, "")
229
+ steps: list[ReasoningStep] = []
230
+ for line in output.split("\n"):
231
+ line = line.strip()
232
+ if line and (line[0].isdigit() or line.startswith("-")):
233
+ desc = line.lstrip("0123456789.-) ").strip()
234
+ if desc:
235
+ steps.append(ReasoningStep(step_description=desc))
236
+ if steps:
237
+ return steps[:max_steps]
238
+ except Exception:
239
+ pass
240
+
241
+ return self._default_decomposition(task_intent, max_steps)
242
+
243
+ def _default_decomposition(
244
+ self, task_intent: str, max_steps: int,
245
+ ) -> list[ReasoningStep]:
246
+ """Default decomposition when LLM unavailable."""
247
+ return [
248
+ ReasoningStep(
249
+ step_description=f"Analyze: {task_intent}",
250
+ expected_output_format="structured analysis",
251
+ scaffold_level=2,
252
+ ),
253
+ ReasoningStep(
254
+ step_description="Synthesize findings into coherent response",
255
+ expected_output_format="summary",
256
+ scaffold_level=1,
257
+ ),
258
+ ][:max_steps]
259
+
260
+ def _synthesize_chain(
261
+ self, step_outputs: list[str], task_intent: str,
262
+ ) -> str:
263
+ """Synthesize step outputs into final response."""
264
+ if self._dispatch_fn and len(step_outputs) > 1:
265
+ joined = "\n\n---\n\n".join(step_outputs)
266
+ try:
267
+ output, _ = self._dispatch_fn(
268
+ f"Synthesize these reasoning steps into a final answer for: {task_intent}",
269
+ joined[:5000],
270
+ )
271
+ return output
272
+ except Exception:
273
+ pass
274
+ return "\n\n".join(step_outputs)
275
+
276
+ # ------------------------------------------------------------------
277
+ # Mechanism 2: In-Context Meta-Learning (ICML)
278
+ # ------------------------------------------------------------------
279
+
280
+ def build_reasoning_scaffold(
281
+ self,
282
+ task_intent: str,
283
+ ) -> str:
284
+ """Build reasoning scaffold adapted to model capability.
285
+
286
+ Capability ≤ 1 (0.5B-1B): Full step-by-step template
287
+ Capability ≤ 2 (2B-7B): Light approach
288
+ Capability > 2: No scaffolding
289
+ """
290
+ level = self.config.scaffold_level
291
+ if level == "auto":
292
+ if self._model_capability <= 1:
293
+ level = "heavy"
294
+ elif self._model_capability <= 2:
295
+ level = "light"
296
+ else:
297
+ level = "none"
298
+
299
+ if level == "none":
300
+ return ""
301
+ if level == "light":
302
+ return (
303
+ f"[APPROACH]\n"
304
+ f"Consider: key factors, potential issues, evidence available.\n"
305
+ f"Task: {task_intent}\n"
306
+ )
307
+ # Heavy scaffolding
308
+ return (
309
+ f"[REASONING APPROACH]\n"
310
+ f"Step 1: Identify the key elements of: {task_intent}\n"
311
+ f"Step 2: Analyze relationships between elements\n"
312
+ f"Step 3: Consider potential issues or gaps\n"
313
+ f"Step 4: Synthesize findings\n"
314
+ f"Step 5: Verify conclusions against evidence\n"
315
+ )
316
+
317
+ def build_metacognitive_envelope(
318
+ self,
319
+ task_intent: str,
320
+ base_envelope: str = "",
321
+ few_shot_traces: list[ReasoningTrace] | None = None,
322
+ ) -> str:
323
+ """Build envelope with reasoning scaffold + few-shot examples."""
324
+ parts: list[str] = []
325
+
326
+ if base_envelope:
327
+ parts.append(base_envelope)
328
+
329
+ scaffold = self.build_reasoning_scaffold(task_intent)
330
+ if scaffold:
331
+ parts.append(scaffold)
332
+
333
+ # Few-shot examples from trace library
334
+ traces = few_shot_traces or self._retrieve_traces(task_intent)
335
+ if traces and self.config.icml_enabled:
336
+ examples = "\n".join(
337
+ f"Example {i+1}: {t.task_summary[:100]} → {t.steps[0].step_description if t.steps else 'N/A'}"
338
+ for i, t in enumerate(traces[:self.config.icml_max_examples])
339
+ )
340
+ parts.append(f"[EXAMPLES]\n{examples}")
341
+
342
+ return "\n\n".join(parts)
343
+
344
+ # ------------------------------------------------------------------
345
+ # Mechanism 3: Reasoning Template Library (RTL)
346
+ # ------------------------------------------------------------------
347
+
348
+ def store_trace(self, trace: ReasoningTrace) -> bool:
349
+ """Store a reasoning trace if quality meets threshold."""
350
+ if not self.config.rtl_enabled:
351
+ return False
352
+ if trace.quality_score < self.config.rtl_min_quality_for_storage:
353
+ return False
354
+ self._trace_library.append(trace)
355
+ return True
356
+
357
+ def _retrieve_traces(
358
+ self,
359
+ task_intent: str,
360
+ top_k: int = 3,
361
+ ) -> list[ReasoningTrace]:
362
+ """Retrieve matching traces from library (simplified text match)."""
363
+ if not self._trace_library:
364
+ return []
365
+ task_words = set(task_intent.lower().split())
366
+ scored: list[tuple[float, ReasoningTrace]] = []
367
+ for trace in self._trace_library:
368
+ trace_words = set(trace.task_summary.lower().split())
369
+ if not task_words:
370
+ continue
371
+ overlap = len(task_words & trace_words) / len(task_words)
372
+ scored.append((overlap, trace))
373
+
374
+ scored.sort(key=lambda x: -x[0])
375
+ results = [t for _, t in scored[:top_k] if _ > 0.2]
376
+ for t in results:
377
+ t.usage_count += 1
378
+ return results
379
+
380
+ @property
381
+ def trace_count(self) -> int:
382
+ return len(self._trace_library)
383
+
384
+ def _model_class_str(self) -> str:
385
+ if self._model_capability <= 1:
386
+ return "0.5B-1B"
387
+ if self._model_capability <= 2:
388
+ return "2B-7B"
389
+ return "7B+"
390
+
391
+ def to_dict(self) -> dict[str, Any]:
392
+ return {
393
+ "traces": [t.to_dict() for t in self._trace_library],
394
+ "config": {
395
+ "enabled": self.config.enabled,
396
+ "orc_enabled": self.config.orc_enabled,
397
+ "orc_max_steps": self.config.orc_max_steps,
398
+ "icml_enabled": self.config.icml_enabled,
399
+ "rtl_enabled": self.config.rtl_enabled,
400
+ },
401
+ }
@@ -0,0 +1,98 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Parallel fan-out — N independent windows dispatched concurrently (§4.4)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+
12
+ @dataclass
13
+ class FanOutTask:
14
+ """One independent task for parallel dispatch."""
15
+
16
+ task_id: str = ""
17
+ system_prompt: str = ""
18
+ task_input: str = ""
19
+ metadata: dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ @dataclass
23
+ class FanOutResult:
24
+ """Result of one parallel dispatch."""
25
+
26
+ task_id: str = ""
27
+ output: str = ""
28
+ facts_extracted: int = 0
29
+ success: bool = True
30
+ error: str | None = None
31
+
32
+
33
+ class ParallelFanOut:
34
+ """Dispatch N independent windows and merge results.
35
+
36
+ Algorithm (§4.4):
37
+ 1. Identify N independent tasks
38
+ 2. Construct independent envelopes from warm_state
39
+ 3. Dispatch all N windows (sequential fallback if no async)
40
+ 4. Collect all N outputs
41
+ 5. Extract facts from all N outputs
42
+ 6. Merge facts into warm_state
43
+ 7. Update DAG with fan-out edges
44
+ 8. Continue with next dependent task
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ dispatch_fn: Callable[[str, str], tuple[str, Any]] | None = None,
50
+ extract_fn: Callable[[str], list[dict[str, Any]]] | None = None,
51
+ max_concurrent: int = 4,
52
+ ) -> None:
53
+ self._dispatch_fn = dispatch_fn
54
+ self._extract_fn = extract_fn
55
+ self._max_concurrent = max_concurrent
56
+
57
+ def fan_out(self, tasks: list[FanOutTask]) -> list[FanOutResult]:
58
+ """Dispatch tasks (sequentially — async version would override).
59
+
60
+ Returns results in same order as tasks.
61
+ """
62
+ results: list[FanOutResult] = []
63
+ for task in tasks:
64
+ if self._dispatch_fn:
65
+ try:
66
+ output, _ = self._dispatch_fn(task.system_prompt, task.task_input)
67
+ facts_count = 0
68
+ if self._extract_fn:
69
+ facts = self._extract_fn(output)
70
+ facts_count = len(facts)
71
+ results.append(FanOutResult(
72
+ task_id=task.task_id,
73
+ output=output,
74
+ facts_extracted=facts_count,
75
+ success=True,
76
+ ))
77
+ except Exception as exc:
78
+ results.append(FanOutResult(
79
+ task_id=task.task_id,
80
+ success=False,
81
+ error=str(exc),
82
+ ))
83
+ else:
84
+ results.append(FanOutResult(
85
+ task_id=task.task_id,
86
+ output=f"[no dispatch_fn] task={task.task_id}",
87
+ ))
88
+ return results
89
+
90
+ def merge_results(
91
+ self,
92
+ results: list[FanOutResult],
93
+ existing_facts: list[Any] | None = None,
94
+ ) -> list[FanOutResult]:
95
+ """Merge fan-out results. Successful results first, failures last."""
96
+ successes = [r for r in results if r.success]
97
+ failures = [r for r in results if not r.success]
98
+ return successes + failures