algo-cli-runtime 0.14.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.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- ollama_cli/__init__.py +67 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""B66. Cross-Source Analysis.
|
|
2
|
+
|
|
3
|
+
Sentiment, consensus, disagreements across sources.
|
|
4
|
+
Source: sibyl pattern.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum, auto
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Sentiment(Enum):
|
|
14
|
+
POSITIVE = auto()
|
|
15
|
+
NEUTRAL = auto()
|
|
16
|
+
NEGATIVE = auto()
|
|
17
|
+
MIXED = auto()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SourcePosition:
|
|
22
|
+
source: str
|
|
23
|
+
title: str
|
|
24
|
+
position: str # summary of what this source says
|
|
25
|
+
sentiment: Sentiment = Sentiment.NEUTRAL
|
|
26
|
+
key_points: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CrossSourceAnalysis:
|
|
31
|
+
question: str
|
|
32
|
+
positions: list[SourcePosition] = field(default_factory=list)
|
|
33
|
+
consensus: list[str] = field(default_factory=list)
|
|
34
|
+
disagreements: list[str] = field(default_factory=list)
|
|
35
|
+
overall_sentiment: Sentiment = Sentiment.NEUTRAL
|
|
36
|
+
source_count: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CrossSourceAnalyzer:
|
|
40
|
+
"""Analyze agreement/disagreement across multiple sources."""
|
|
41
|
+
|
|
42
|
+
POSITIVE_WORDS = {"good", "great", "excellent", "best", "recommend", "fast", "efficient", "easy", "powerful", "effective"}
|
|
43
|
+
NEGATIVE_WORDS = {"bad", "poor", "slow", "broken", "fail", "issue", "problem", "difficult", "complex", "deprecated"}
|
|
44
|
+
|
|
45
|
+
def analyze(self, question: str,
|
|
46
|
+
sources: list[tuple[str, str, str]]) -> CrossSourceAnalysis:
|
|
47
|
+
"""Analyze positions across sources.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
question: The question being analyzed
|
|
51
|
+
sources: List of (source_name, title, content)
|
|
52
|
+
"""
|
|
53
|
+
result = CrossSourceAnalysis(question=question, source_count=len(sources))
|
|
54
|
+
|
|
55
|
+
for src_name, title, content in sources:
|
|
56
|
+
position = SourcePosition(source=src_name, title=title, position=content[:200])
|
|
57
|
+
position.sentiment = self._detect_sentiment(content)
|
|
58
|
+
position.key_points = self._extract_key_points(content)
|
|
59
|
+
result.positions.append(position)
|
|
60
|
+
|
|
61
|
+
result.consensus = self._find_consensus(result.positions)
|
|
62
|
+
result.disagreements = self._find_disagreements(result.positions)
|
|
63
|
+
result.overall_sentiment = self._overall_sentiment(result.positions)
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
def _detect_sentiment(self, text: str) -> Sentiment:
|
|
68
|
+
words = set(text.lower().split())
|
|
69
|
+
pos = len(words & self.POSITIVE_WORDS)
|
|
70
|
+
neg = len(words & self.NEGATIVE_WORDS)
|
|
71
|
+
if pos > neg and pos > 0:
|
|
72
|
+
return Sentiment.POSITIVE
|
|
73
|
+
elif neg > pos and neg > 0:
|
|
74
|
+
return Sentiment.NEGATIVE
|
|
75
|
+
elif pos > 0 and neg > 0:
|
|
76
|
+
return Sentiment.MIXED
|
|
77
|
+
return Sentiment.NEUTRAL
|
|
78
|
+
|
|
79
|
+
def _extract_key_points(self, content: str) -> list[str]:
|
|
80
|
+
sentences = re.split(r"[.!?]+", content)
|
|
81
|
+
return [s.strip() for s in sentences if len(s.strip()) > 20][:5]
|
|
82
|
+
|
|
83
|
+
def _find_consensus(self, positions: list[SourcePosition]) -> list[str]:
|
|
84
|
+
"""Find points mentioned by multiple sources."""
|
|
85
|
+
point_counts: dict[str, int] = {}
|
|
86
|
+
for pos in positions:
|
|
87
|
+
for point in pos.key_points:
|
|
88
|
+
key = point.lower()[:50]
|
|
89
|
+
point_counts[key] = point_counts.get(key, 0) + 1
|
|
90
|
+
return [p for p, c in point_counts.items() if c >= 2]
|
|
91
|
+
|
|
92
|
+
def _find_disagreements(self, positions: list[SourcePosition]) -> list[str]:
|
|
93
|
+
"""Find where sources have different sentiments."""
|
|
94
|
+
disagreements: list[str] = []
|
|
95
|
+
sentiments = {p.source: p.sentiment for p in positions}
|
|
96
|
+
unique = set(sentiments.values())
|
|
97
|
+
if len(unique) > 1:
|
|
98
|
+
disagreements.append(f"Sources disagree on sentiment: {sentiments}")
|
|
99
|
+
return disagreements
|
|
100
|
+
|
|
101
|
+
def _overall_sentiment(self, positions: list[SourcePosition]) -> Sentiment:
|
|
102
|
+
if not positions:
|
|
103
|
+
return Sentiment.NEUTRAL
|
|
104
|
+
sentiments = [p.sentiment for p in positions]
|
|
105
|
+
pos = sum(1 for s in sentiments if s == Sentiment.POSITIVE)
|
|
106
|
+
neg = sum(1 for s in sentiments if s == Sentiment.NEGATIVE)
|
|
107
|
+
if pos > neg:
|
|
108
|
+
return Sentiment.POSITIVE
|
|
109
|
+
elif neg > pos:
|
|
110
|
+
return Sentiment.NEGATIVE
|
|
111
|
+
elif pos > 0 and neg > 0:
|
|
112
|
+
return Sentiment.MIXED
|
|
113
|
+
return Sentiment.NEUTRAL
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""B59. Daemon Mode: Multi-Client Shared Agent.
|
|
2
|
+
|
|
3
|
+
HTTP+SSE server: multiple clients share one agent session.
|
|
4
|
+
Source: qwen-code pattern.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ClientSession:
|
|
15
|
+
client_id: str
|
|
16
|
+
connected_at: float = field(default_factory=time.time)
|
|
17
|
+
last_active: float = field(default_factory=time.time)
|
|
18
|
+
subscriptions: set[str] = field(default_factory=set)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SharedMessage:
|
|
23
|
+
id: str
|
|
24
|
+
role: str # "user", "assistant", "system"
|
|
25
|
+
content: str
|
|
26
|
+
client_id: str | None = None # who sent it (None = system)
|
|
27
|
+
timestamp: float = field(default_factory=time.time)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DaemonAgent:
|
|
31
|
+
"""Shared agent session that multiple clients can connect to."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, max_clients: int = 10, max_messages: int = 1000) -> None:
|
|
34
|
+
self._clients: dict[str, ClientSession] = {}
|
|
35
|
+
self._messages: list[SharedMessage] = []
|
|
36
|
+
self._max_clients = max_clients
|
|
37
|
+
self._max_messages = max_messages
|
|
38
|
+
self._message_counter = 0
|
|
39
|
+
self._event_handlers: dict[str, list[Callable]] = {}
|
|
40
|
+
|
|
41
|
+
def connect(self, client_id: str) -> ClientSession:
|
|
42
|
+
if len(self._clients) >= self._max_clients:
|
|
43
|
+
raise RuntimeError(f"Max clients ({self._max_clients}) reached")
|
|
44
|
+
if client_id in self._clients:
|
|
45
|
+
return self._clients[client_id]
|
|
46
|
+
session = ClientSession(client_id=client_id)
|
|
47
|
+
self._clients[client_id] = session
|
|
48
|
+
self._emit("client_connected", client_id)
|
|
49
|
+
return session
|
|
50
|
+
|
|
51
|
+
def disconnect(self, client_id: str) -> None:
|
|
52
|
+
self._clients.pop(client_id, None)
|
|
53
|
+
self._emit("client_disconnected", client_id)
|
|
54
|
+
|
|
55
|
+
def send_message(self, client_id: str, role: str, content: str) -> SharedMessage:
|
|
56
|
+
self._message_counter += 1
|
|
57
|
+
msg = SharedMessage(
|
|
58
|
+
id=f"msg_{self._message_counter}",
|
|
59
|
+
role=role,
|
|
60
|
+
content=content,
|
|
61
|
+
client_id=client_id,
|
|
62
|
+
)
|
|
63
|
+
self._messages.append(msg)
|
|
64
|
+
if len(self._messages) > self._max_messages:
|
|
65
|
+
self._messages = self._messages[-self._max_messages:]
|
|
66
|
+
self._emit("message", msg)
|
|
67
|
+
return msg
|
|
68
|
+
|
|
69
|
+
def get_messages(self, since_id: str | None = None) -> list[SharedMessage]:
|
|
70
|
+
if since_id is None:
|
|
71
|
+
return list(self._messages)
|
|
72
|
+
idx = next(
|
|
73
|
+
(i for i, m in enumerate(self._messages) if m.id == since_id),
|
|
74
|
+
0,
|
|
75
|
+
)
|
|
76
|
+
return self._messages[idx + 1:]
|
|
77
|
+
|
|
78
|
+
def subscribe(self, client_id: str, event: str) -> None:
|
|
79
|
+
session = self._clients.get(client_id)
|
|
80
|
+
if session:
|
|
81
|
+
session.subscriptions.add(event)
|
|
82
|
+
|
|
83
|
+
def on_event(self, event: str, handler: Callable) -> None:
|
|
84
|
+
self._event_handlers.setdefault(event, []).append(handler)
|
|
85
|
+
|
|
86
|
+
def _emit(self, event: str, data: Any) -> None:
|
|
87
|
+
for handler in self._event_handlers.get(event, []):
|
|
88
|
+
try:
|
|
89
|
+
handler(data)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def client_count(self) -> int:
|
|
95
|
+
return len(self._clients)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def message_count(self) -> int:
|
|
99
|
+
return len(self._messages)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""B53. Dependency Graph Agent Orchestration.
|
|
2
|
+
|
|
3
|
+
Orchestrate agents as a dependency graph with parallel fanout,
|
|
4
|
+
iterative cycles, and scratchboard shared memory.
|
|
5
|
+
Source: agentflow pattern.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum, auto
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NodeStatus(Enum):
|
|
15
|
+
PENDING = auto()
|
|
16
|
+
RUNNING = auto()
|
|
17
|
+
DONE = auto()
|
|
18
|
+
FAILED = auto()
|
|
19
|
+
SKIPPED = auto()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DAGNode:
|
|
24
|
+
id: str
|
|
25
|
+
execute: Callable[[dict], Any]
|
|
26
|
+
depends_on: list[str] = field(default_factory=list)
|
|
27
|
+
status: NodeStatus = NodeStatus.PENDING
|
|
28
|
+
result: Any = None
|
|
29
|
+
error: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class DAGEdge:
|
|
34
|
+
source: str
|
|
35
|
+
target: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CycleSpec:
|
|
40
|
+
"""Iterative cycle: repeat until success criteria met."""
|
|
41
|
+
nodes: list[str]
|
|
42
|
+
max_iterations: int = 5
|
|
43
|
+
success_check: Callable[[dict], bool] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DependencyGraph:
|
|
47
|
+
"""DAG executor with parallel fanout and iterative cycles."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._nodes: dict[str, DAGNode] = {}
|
|
51
|
+
self._edges: list[DAGEdge] = []
|
|
52
|
+
self._cycles: list[CycleSpec] = []
|
|
53
|
+
self._scratchboard: dict[str, Any] = {}
|
|
54
|
+
|
|
55
|
+
def add_node(self, node: DAGNode) -> None:
|
|
56
|
+
self._nodes[node.id] = node
|
|
57
|
+
|
|
58
|
+
def add_edge(self, source: str, target: str) -> None:
|
|
59
|
+
self._edges.append(DAGEdge(source, target))
|
|
60
|
+
|
|
61
|
+
def add_cycle(self, cycle: CycleSpec) -> None:
|
|
62
|
+
self._cycles.append(cycle)
|
|
63
|
+
|
|
64
|
+
def topological_order(self) -> list[str]:
|
|
65
|
+
"""Kahn's algorithm for topological sort."""
|
|
66
|
+
in_degree: dict[str, int] = {n: 0 for n in self._nodes}
|
|
67
|
+
adj: dict[str, list[str]] = {n: [] for n in self._nodes}
|
|
68
|
+
for e in self._edges:
|
|
69
|
+
adj[e.source].append(e.target)
|
|
70
|
+
in_degree[e.target] += 1
|
|
71
|
+
|
|
72
|
+
queue = [n for n, d in in_degree.items() if d == 0]
|
|
73
|
+
order: list[str] = []
|
|
74
|
+
while queue:
|
|
75
|
+
node = queue.pop(0)
|
|
76
|
+
order.append(node)
|
|
77
|
+
for neighbor in adj[node]:
|
|
78
|
+
in_degree[neighbor] -= 1
|
|
79
|
+
if in_degree[neighbor] == 0:
|
|
80
|
+
queue.append(neighbor)
|
|
81
|
+
|
|
82
|
+
if len(order) != len(self._nodes):
|
|
83
|
+
raise ValueError("Cycle detected in DAG")
|
|
84
|
+
return order
|
|
85
|
+
|
|
86
|
+
def _get_ready_nodes(self) -> list[str]:
|
|
87
|
+
"""Nodes whose dependencies are all DONE."""
|
|
88
|
+
ready = []
|
|
89
|
+
for nid, node in self._nodes.items():
|
|
90
|
+
if node.status != NodeStatus.PENDING:
|
|
91
|
+
continue
|
|
92
|
+
deps = self._dependencies(nid)
|
|
93
|
+
if all(self._nodes[d].status == NodeStatus.DONE for d in deps):
|
|
94
|
+
ready.append(nid)
|
|
95
|
+
return ready
|
|
96
|
+
|
|
97
|
+
def _dependencies(self, node_id: str) -> list[str]:
|
|
98
|
+
return [e.source for e in self._edges if e.target == node_id]
|
|
99
|
+
|
|
100
|
+
def run(self, initial_context: dict | None = None) -> dict[str, Any]:
|
|
101
|
+
"""Execute the DAG, returning results for all nodes."""
|
|
102
|
+
ctx = initial_context or {}
|
|
103
|
+
ctx.update(self._scratchboard)
|
|
104
|
+
|
|
105
|
+
for _ in range(len(self._nodes) * 2): # safety limit
|
|
106
|
+
ready = self._get_ready_nodes()
|
|
107
|
+
if not ready:
|
|
108
|
+
break
|
|
109
|
+
for nid in ready:
|
|
110
|
+
node = self._nodes[nid]
|
|
111
|
+
node.status = NodeStatus.RUNNING
|
|
112
|
+
try:
|
|
113
|
+
dep_results = {d: self._nodes[d].result for d in self._dependencies(nid)}
|
|
114
|
+
ctx.setdefault("_results", {}).update(dep_results)
|
|
115
|
+
node.result = node.execute(ctx)
|
|
116
|
+
if node.result is not None:
|
|
117
|
+
ctx[nid] = node.result
|
|
118
|
+
node.status = NodeStatus.DONE
|
|
119
|
+
except Exception as e:
|
|
120
|
+
node.error = str(e)
|
|
121
|
+
node.status = NodeStatus.FAILED
|
|
122
|
+
ctx.setdefault("_errors", []).append({"node": nid, "error": str(e)})
|
|
123
|
+
|
|
124
|
+
# Handle cycles
|
|
125
|
+
for cycle in self._cycles:
|
|
126
|
+
for iteration in range(cycle.max_iterations):
|
|
127
|
+
for nid in cycle.nodes:
|
|
128
|
+
node = self._nodes[nid]
|
|
129
|
+
node.status = NodeStatus.PENDING
|
|
130
|
+
for nid in cycle.nodes:
|
|
131
|
+
node = self._nodes[nid]
|
|
132
|
+
node.status = NodeStatus.RUNNING
|
|
133
|
+
try:
|
|
134
|
+
dep_results = {d: self._nodes[d].result for d in self._dependencies(nid)}
|
|
135
|
+
ctx.setdefault("_results", {}).update(dep_results)
|
|
136
|
+
node.result = node._DAGNode__wrapped_execute(ctx) if hasattr(node, "_DAGNode__wrapped_execute") else node.execute(ctx)
|
|
137
|
+
if node.result is not None:
|
|
138
|
+
ctx[nid] = node.result
|
|
139
|
+
node.status = NodeStatus.DONE
|
|
140
|
+
except Exception:
|
|
141
|
+
node.status = NodeStatus.FAILED
|
|
142
|
+
|
|
143
|
+
if cycle.success_check and cycle.success_check(ctx):
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
self._scratchboard.update(ctx)
|
|
147
|
+
return dict(ctx)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def scratchboard(self) -> dict[str, Any]:
|
|
151
|
+
return dict(self._scratchboard)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""B62. Sub-Question Decomposition + Iterative Gap-Filling.
|
|
2
|
+
|
|
3
|
+
Decompose a complex query into sub-questions, search for each,
|
|
4
|
+
identify gaps, and search again until coverage is sufficient.
|
|
5
|
+
Source: sibyl pattern.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum, auto
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GapType(Enum):
|
|
15
|
+
MISSING = auto() # no answer found
|
|
16
|
+
SHALLOW = auto() # answer too brief
|
|
17
|
+
STALE = auto() # answer references old info
|
|
18
|
+
CONTRADICTORY = auto() # conflicting answers
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SubQuestion:
|
|
23
|
+
id: str
|
|
24
|
+
question: str
|
|
25
|
+
search_queries: list[str] = field(default_factory=list)
|
|
26
|
+
answer: str = ""
|
|
27
|
+
sources: list[str] = field(default_factory=list)
|
|
28
|
+
confidence: float = 0.0
|
|
29
|
+
gaps: list[GapType] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class DecompositionResult:
|
|
34
|
+
original_query: str
|
|
35
|
+
sub_questions: list[SubQuestion] = field(default_factory=list)
|
|
36
|
+
iterations: int = 0
|
|
37
|
+
coverage_score: float = 0.0
|
|
38
|
+
summary: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class QueryDecomposer:
|
|
42
|
+
"""Decompose a complex query into sub-questions."""
|
|
43
|
+
|
|
44
|
+
QUESTION_PREFIXES = [
|
|
45
|
+
"What is", "How does", "Why is", "When was", "Where is",
|
|
46
|
+
"Who is", "What are the key features of",
|
|
47
|
+
"What are the limitations of", "How is X different from",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
def decompose(self, query: str, max_sub_questions: int = 5) -> list[SubQuestion]:
|
|
51
|
+
"""Split a complex query into focused sub-questions."""
|
|
52
|
+
# Simple heuristic: split on conjunctions and question marks
|
|
53
|
+
parts = query.replace(" and ", "|").replace(" vs ", "|").split("|")
|
|
54
|
+
sub_qs: list[SubQuestion] = []
|
|
55
|
+
for i, part in enumerate(parts):
|
|
56
|
+
part = part.strip().rstrip("?")
|
|
57
|
+
if not part:
|
|
58
|
+
continue
|
|
59
|
+
sq = SubQuestion(id=f"sq_{i+1}", question=part)
|
|
60
|
+
# Generate search queries
|
|
61
|
+
sq.search_queries = [part, f"{part} overview", f"{part} explained"]
|
|
62
|
+
sub_qs.append(sq)
|
|
63
|
+
if len(sub_qs) >= max_sub_questions:
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
if not sub_qs:
|
|
67
|
+
sub_qs.append(SubQuestion(id="sq_1", question=query, search_queries=[query]))
|
|
68
|
+
|
|
69
|
+
return sub_qs
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class GapFiller:
|
|
73
|
+
"""Identify gaps in answers and generate follow-up searches."""
|
|
74
|
+
|
|
75
|
+
def identify_gaps(self, sq: SubQuestion) -> list[GapType]:
|
|
76
|
+
gaps: list[GapType] = []
|
|
77
|
+
if not sq.answer:
|
|
78
|
+
gaps.append(GapType.MISSING)
|
|
79
|
+
elif len(sq.answer) < 100:
|
|
80
|
+
gaps.append(GapType.SHALLOW)
|
|
81
|
+
if sq.confidence < 0.5:
|
|
82
|
+
gaps.append(GapType.MISSING)
|
|
83
|
+
return gaps
|
|
84
|
+
|
|
85
|
+
def generate_followup(self, sq: SubQuestion) -> list[str]:
|
|
86
|
+
"""Generate follow-up search queries for gaps."""
|
|
87
|
+
followups: list[str] = []
|
|
88
|
+
for gap in sq.gaps:
|
|
89
|
+
if gap == GapType.MISSING:
|
|
90
|
+
followups.append(f"{sq.question} detailed explanation")
|
|
91
|
+
followups.append(f"{sq.question} examples")
|
|
92
|
+
elif gap == GapType.SHALLOW:
|
|
93
|
+
followups.append(f"{sq.question} in depth analysis")
|
|
94
|
+
elif gap == GapType.STALE:
|
|
95
|
+
followups.append(f"{sq.question} 2025 2026 latest")
|
|
96
|
+
return followups
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DeepResearchEngine:
|
|
100
|
+
"""Orchestrate sub-question decomposition with iterative gap-filling."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, search_fn: Callable[[str], list[tuple[str, str]]],
|
|
103
|
+
max_iterations: int = 3, coverage_threshold: float = 0.8) -> None:
|
|
104
|
+
self._search = search_fn
|
|
105
|
+
self._max_iterations = max_iterations
|
|
106
|
+
self._coverage_threshold = coverage_threshold
|
|
107
|
+
self._decomposer = QueryDecomposer()
|
|
108
|
+
self._gap_filler = GapFiller()
|
|
109
|
+
|
|
110
|
+
def run(self, query: str) -> DecompositionResult:
|
|
111
|
+
result = DecompositionResult(original_query=query)
|
|
112
|
+
result.sub_questions = self._decomposer.decompose(query)
|
|
113
|
+
|
|
114
|
+
for iteration in range(self._max_iterations):
|
|
115
|
+
result.iterations = iteration + 1
|
|
116
|
+
|
|
117
|
+
for sq in result.sub_questions:
|
|
118
|
+
if sq.confidence >= 0.7:
|
|
119
|
+
continue
|
|
120
|
+
# Search for each query
|
|
121
|
+
for search_q in sq.search_queries:
|
|
122
|
+
results = self._search(search_q)
|
|
123
|
+
for title, content in results:
|
|
124
|
+
sq.sources.append(title)
|
|
125
|
+
if not sq.answer:
|
|
126
|
+
sq.answer = content
|
|
127
|
+
else:
|
|
128
|
+
sq.answer += "\n\n" + content
|
|
129
|
+
if results:
|
|
130
|
+
sq.confidence = min(1.0, sq.confidence + 0.3)
|
|
131
|
+
|
|
132
|
+
# Check for gaps
|
|
133
|
+
sq.gaps = self._gap_filler.identify_gaps(sq)
|
|
134
|
+
if sq.gaps:
|
|
135
|
+
followups = self._gap_filler.generate_followup(sq)
|
|
136
|
+
sq.search_queries.extend(followups)
|
|
137
|
+
|
|
138
|
+
# Compute coverage
|
|
139
|
+
answered = sum(1 for sq in result.sub_questions if sq.confidence >= 0.7)
|
|
140
|
+
result.coverage_score = answered / len(result.sub_questions) if result.sub_questions else 0.0
|
|
141
|
+
|
|
142
|
+
if result.coverage_score >= self._coverage_threshold:
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
result.summary = self._synthesize(result)
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
def _synthesize(self, result: DecompositionResult) -> str:
|
|
149
|
+
lines = [f"# Research: {result.original_query}", ""]
|
|
150
|
+
for sq in result.sub_questions:
|
|
151
|
+
lines.append(f"## {sq.question}")
|
|
152
|
+
lines.append(sq.answer[:500] if sq.answer else "(no answer found)")
|
|
153
|
+
lines.append("")
|
|
154
|
+
lines.append(f"Coverage: {result.coverage_score:.0%} after {result.iterations} iterations")
|
|
155
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""H27 — Degenerate Solution Detection.
|
|
2
|
+
|
|
3
|
+
Catch solutions that precompute answers without required constructs.
|
|
4
|
+
Mined from GLOSSOPETRAE gradeStructured() — degenerate_precompute detection.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class DegenerateReport:
|
|
14
|
+
"""Report on whether a solution is degenerate."""
|
|
15
|
+
|
|
16
|
+
is_degenerate: bool
|
|
17
|
+
reason: str = ""
|
|
18
|
+
has_loops: bool = False
|
|
19
|
+
has_functions: bool = False
|
|
20
|
+
has_conditionals: bool = False
|
|
21
|
+
literal_count: int = 0
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, object]:
|
|
24
|
+
return {
|
|
25
|
+
"is_degenerate": self.is_degenerate,
|
|
26
|
+
"reason": self.reason,
|
|
27
|
+
"has_loops": self.has_loops,
|
|
28
|
+
"has_functions": self.has_functions,
|
|
29
|
+
"has_conditionals": self.has_conditionals,
|
|
30
|
+
"literal_count": self.literal_count,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DegenerateDetector:
|
|
35
|
+
"""Detect programs that precompute answers without algorithmic structure."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, require_loops: bool = True, require_functions: bool = True) -> None:
|
|
38
|
+
self.require_loops = require_loops
|
|
39
|
+
self.require_functions = require_functions
|
|
40
|
+
|
|
41
|
+
def analyze(self, code: str) -> DegenerateReport:
|
|
42
|
+
try:
|
|
43
|
+
tree = ast.parse(code)
|
|
44
|
+
except SyntaxError as e:
|
|
45
|
+
return DegenerateReport(
|
|
46
|
+
is_degenerate=True,
|
|
47
|
+
reason=f"Syntax error: {e}",
|
|
48
|
+
)
|
|
49
|
+
has_loops = False
|
|
50
|
+
has_functions = False
|
|
51
|
+
has_conditionals = False
|
|
52
|
+
literal_count = 0
|
|
53
|
+
for node in ast.walk(tree):
|
|
54
|
+
if isinstance(node, (ast.For, ast.While)):
|
|
55
|
+
has_loops = True
|
|
56
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
57
|
+
has_functions = True
|
|
58
|
+
elif isinstance(node, ast.If):
|
|
59
|
+
has_conditionals = True
|
|
60
|
+
elif isinstance(node, ast.Constant):
|
|
61
|
+
literal_count += 1
|
|
62
|
+
missing = []
|
|
63
|
+
if self.require_loops and not has_loops:
|
|
64
|
+
missing.append("loops")
|
|
65
|
+
if self.require_functions and not has_functions:
|
|
66
|
+
missing.append("functions")
|
|
67
|
+
is_degenerate = len(missing) > 0 and literal_count > 0
|
|
68
|
+
reason = ""
|
|
69
|
+
if is_degenerate:
|
|
70
|
+
reason = f"Solution has no {' or '.join(missing)} but contains {literal_count} literals — likely precomputed"
|
|
71
|
+
return DegenerateReport(
|
|
72
|
+
is_degenerate=is_degenerate,
|
|
73
|
+
reason=reason,
|
|
74
|
+
has_loops=has_loops,
|
|
75
|
+
has_functions=has_functions,
|
|
76
|
+
has_conditionals=has_conditionals,
|
|
77
|
+
literal_count=literal_count,
|
|
78
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""H30 — Delta Reporting.
|
|
2
|
+
|
|
3
|
+
Report what changed since last assessment, not full reports.
|
|
4
|
+
Mined from T3MP3ST VISION.md Vector 4.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DeltaEntry:
|
|
15
|
+
"""A single change in a delta report."""
|
|
16
|
+
|
|
17
|
+
change_type: str # "added", "removed", "changed"
|
|
18
|
+
target_id: str
|
|
19
|
+
old_value: Any = None
|
|
20
|
+
new_value: Any = None
|
|
21
|
+
timestamp: float = field(default_factory=time.time)
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"change_type": self.change_type,
|
|
26
|
+
"target_id": self.target_id,
|
|
27
|
+
"old_value": self.old_value,
|
|
28
|
+
"new_value": self.new_value,
|
|
29
|
+
"timestamp": self.timestamp,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DeltaReport:
|
|
35
|
+
"""A report of changes between two states."""
|
|
36
|
+
|
|
37
|
+
entries: list[DeltaEntry] = field(default_factory=list)
|
|
38
|
+
generated_at: float = field(default_factory=time.time)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def added(self) -> list[DeltaEntry]:
|
|
42
|
+
return [e for e in self.entries if e.change_type == "added"]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def removed(self) -> list[DeltaEntry]:
|
|
46
|
+
return [e for e in self.entries if e.change_type == "removed"]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def changed(self) -> list[DeltaEntry]:
|
|
50
|
+
return [e for e in self.entries if e.change_type == "changed"]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_empty(self) -> bool:
|
|
54
|
+
return len(self.entries) == 0
|
|
55
|
+
|
|
56
|
+
def summary(self) -> str:
|
|
57
|
+
return (
|
|
58
|
+
f"Delta: +{len(self.added)} added, -{len(self.removed)} removed, "
|
|
59
|
+
f"~{len(self.changed)} changed"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"entries": [e.to_dict() for e in self.entries],
|
|
65
|
+
"generated_at": self.generated_at,
|
|
66
|
+
"summary": self.summary(),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DeltaReporter:
|
|
71
|
+
"""Compute deltas between two state snapshots."""
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def compute(
|
|
75
|
+
previous: dict[str, Any],
|
|
76
|
+
current: dict[str, Any],
|
|
77
|
+
) -> DeltaReport:
|
|
78
|
+
"""Compute delta between two state dicts keyed by ID."""
|
|
79
|
+
entries: list[DeltaEntry] = []
|
|
80
|
+
prev_keys = set(previous.keys())
|
|
81
|
+
curr_keys = set(current.keys())
|
|
82
|
+
# Added
|
|
83
|
+
for key in sorted(curr_keys - prev_keys):
|
|
84
|
+
entries.append(DeltaEntry("added", key, new_value=current[key]))
|
|
85
|
+
# Removed
|
|
86
|
+
for key in sorted(prev_keys - curr_keys):
|
|
87
|
+
entries.append(DeltaEntry("removed", key, old_value=previous[key]))
|
|
88
|
+
# Changed
|
|
89
|
+
for key in sorted(prev_keys & curr_keys):
|
|
90
|
+
if previous[key] != current[key]:
|
|
91
|
+
entries.append(DeltaEntry("changed", key, previous[key], current[key]))
|
|
92
|
+
return DeltaReport(entries=entries)
|