continuum-toolkit 1.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 (72) hide show
  1. cli/__init__.py +12 -0
  2. cli/main.py +415 -0
  3. confidence/__init__.py +13 -0
  4. confidence/calculator.py +308 -0
  5. confidence/models.py +40 -0
  6. context/__init__.py +17 -0
  7. context/models.py +108 -0
  8. context/pruner.py +228 -0
  9. context/selector.py +193 -0
  10. continuum_toolkit-1.0.0.dist-info/METADATA +511 -0
  11. continuum_toolkit-1.0.0.dist-info/RECORD +72 -0
  12. continuum_toolkit-1.0.0.dist-info/WHEEL +5 -0
  13. continuum_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  14. continuum_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  15. continuum_toolkit-1.0.0.dist-info/top_level.txt +13 -0
  16. contradictions/__init__.py +19 -0
  17. contradictions/detector.py +442 -0
  18. contradictions/models.py +75 -0
  19. core/__init__.py +97 -0
  20. core/enums.py +130 -0
  21. core/evidence.py +117 -0
  22. core/interfaces.py +209 -0
  23. core/schema.py +391 -0
  24. core/serializer.py +84 -0
  25. core/state_models.py +530 -0
  26. daemon/__init__.py +12 -0
  27. daemon/service.py +170 -0
  28. extractors/__init__.py +51 -0
  29. extractors/base.py +117 -0
  30. extractors/config/parsers.py +288 -0
  31. extractors/config/secret_sanitizer.py +91 -0
  32. extractors/config_extractor.py +172 -0
  33. extractors/conversation/analyzers.py +193 -0
  34. extractors/conversation/models.py +148 -0
  35. extractors/conversation_extractor.py +166 -0
  36. extractors/git_extractor.py +305 -0
  37. extractors/parsers/base.py +91 -0
  38. extractors/parsers/comment_parser.py +51 -0
  39. extractors/parsers/js_ts_parser.py +171 -0
  40. extractors/parsers/python_parser.py +180 -0
  41. extractors/verification/runners.py +278 -0
  42. extractors/verification_extractor.py +263 -0
  43. extractors/workspace_extractor.py +221 -0
  44. graph/__init__.py +24 -0
  45. graph/diff.py +109 -0
  46. graph/manager.py +473 -0
  47. graph/models.py +62 -0
  48. graph/propagator.py +194 -0
  49. graph/query.py +86 -0
  50. graph/snapshot.py +85 -0
  51. handoff/__init__.py +28 -0
  52. handoff/adapters/__init__.py +45 -0
  53. handoff/adapters/base.py +90 -0
  54. handoff/adapters/claude_adapter.py +176 -0
  55. handoff/adapters/codex_gpt_adapter.py +151 -0
  56. handoff/adapters/gemini_adapter.py +151 -0
  57. handoff/adapters/local_model_adapter.py +130 -0
  58. handoff/models.py +88 -0
  59. handoff/packager.py +288 -0
  60. pipeline/__init__.py +9 -0
  61. pipeline/orchestrator.py +260 -0
  62. resolution/__init__.py +15 -0
  63. resolution/resolver.py +311 -0
  64. storage/__init__.py +18 -0
  65. storage/hooks.py +125 -0
  66. storage/manager.py +127 -0
  67. storage/models.py +39 -0
  68. storage/recovery.py +98 -0
  69. watcher/__init__.py +17 -0
  70. watcher/detector.py +136 -0
  71. watcher/models.py +62 -0
  72. watcher/updater.py +163 -0
@@ -0,0 +1,263 @@
1
+ """
2
+ Project Continuum - Test, Build & Verification Evidence Extractor
3
+ =================================================================
4
+ Milestone 2 - Phase 4: Test, Build & Verification Evidence Collection.
5
+ Discovers available test frameworks, explicitly executes configured verification
6
+ commands, records exit codes, durations, parsed outcomes, and registers
7
+ highest-priority Level 1 canonical Evidence.
8
+ """
9
+
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ from core.enums import EvidenceType, EvidenceLevel, Status
14
+ from core.evidence import Evidence
15
+ from core.state_models import (
16
+ CanonicalProjectState,
17
+ ManifestInfo,
18
+ ProjectState,
19
+ TestResult,
20
+ )
21
+ from extractors.base import BaseEvidenceExtractor
22
+ from extractors.verification.runners import (
23
+ ExecutionResult,
24
+ ParsedTestSummary,
25
+ TestOutputParser,
26
+ VerificationRunner,
27
+ )
28
+
29
+
30
+ class VerificationEvidenceExtractor(BaseEvidenceExtractor):
31
+ """
32
+ Extractor responsible for Phase 4: Verification, Test, and Build Evidence.
33
+ Adheres strictly to the rule: Never run scripts automatically; only discover
34
+ configurations or run when explicitly requested.
35
+ """
36
+
37
+ @property
38
+ def extractor_name(self) -> str:
39
+ return "VerificationEvidenceExtractor"
40
+
41
+ @property
42
+ def supported_evidence_types(self) -> List[EvidenceType]:
43
+ return [
44
+ EvidenceType.TEST_RUN,
45
+ EvidenceType.BUILD_LOG,
46
+ EvidenceType.RUNTIME_LOG,
47
+ ]
48
+
49
+ def can_extract(self, target_path_or_input: str) -> bool:
50
+ path = Path(target_path_or_input)
51
+ return path.exists()
52
+
53
+ def discover_verification_configs(self, root_path: Path, manifests: Optional[List[ManifestInfo]] = None) -> List[Dict[str, Any]]:
54
+ """
55
+ Scans project directory and manifests for available test and build commands
56
+ WITHOUT automatically executing any of them.
57
+ """
58
+ discovered: List[Dict[str, Any]] = []
59
+
60
+ # 1. Check Python tools
61
+ if (root_path / "pyproject.toml").exists() or (root_path / "pytest.ini").exists() or (root_path / "tests").exists():
62
+ discovered.append({
63
+ "framework": "pytest",
64
+ "type": "test",
65
+ "recommended_command": "pytest",
66
+ "config_file": "pytest.ini" if (root_path / "pytest.ini").exists() else "pyproject.toml"
67
+ })
68
+ discovered.append({
69
+ "framework": "unittest",
70
+ "type": "test",
71
+ "recommended_command": "py -m unittest discover",
72
+ "config_file": None
73
+ })
74
+
75
+ # 2. Check JavaScript / TypeScript (package.json scripts)
76
+ pkg_json = root_path / "package.json"
77
+ if pkg_json.exists():
78
+ try:
79
+ import json
80
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
81
+ scripts = data.get("scripts", {})
82
+ if "test" in scripts:
83
+ discovered.append({
84
+ "framework": "npm_test",
85
+ "type": "test",
86
+ "recommended_command": "npm test",
87
+ "script_body": scripts["test"]
88
+ })
89
+ if "build" in scripts:
90
+ discovered.append({
91
+ "framework": "npm_build",
92
+ "type": "build",
93
+ "recommended_command": "npm run build",
94
+ "script_body": scripts["build"]
95
+ })
96
+ except Exception:
97
+ pass
98
+
99
+ # 3. Check Rust
100
+ if (root_path / "Cargo.toml").exists():
101
+ discovered.append({
102
+ "framework": "cargo_test",
103
+ "type": "test",
104
+ "recommended_command": "cargo test",
105
+ "config_file": "Cargo.toml"
106
+ })
107
+ discovered.append({
108
+ "framework": "cargo_build",
109
+ "type": "build",
110
+ "recommended_command": "cargo build",
111
+ "config_file": "Cargo.toml"
112
+ })
113
+
114
+ # 4. Check Go
115
+ if (root_path / "go.mod").exists():
116
+ discovered.append({
117
+ "framework": "go_test",
118
+ "type": "test",
119
+ "recommended_command": "go test ./...",
120
+ "config_file": "go.mod"
121
+ })
122
+ discovered.append({
123
+ "framework": "go_build",
124
+ "type": "build",
125
+ "recommended_command": "go build ./...",
126
+ "config_file": "go.mod"
127
+ })
128
+
129
+ return discovered
130
+
131
+ def run_verification(
132
+ self,
133
+ root_path: Path | str,
134
+ command: str | List[str],
135
+ suite_name: str = "default_suite",
136
+ is_build: bool = False,
137
+ timeout_seconds: int = 60
138
+ ) -> Tuple[ExecutionResult, ParsedTestSummary, Evidence]:
139
+ """
140
+ Explicitly executes a verification command and produces Level 1 canonical Evidence.
141
+ """
142
+ root = Path(root_path).resolve()
143
+ exec_result = VerificationRunner.run_command(command, cwd=root, timeout_seconds=timeout_seconds)
144
+
145
+ parsed_summary = TestOutputParser.parse(
146
+ stdout=exec_result.stdout,
147
+ stderr=exec_result.stderr,
148
+ exit_code=exec_result.exit_code,
149
+ duration_ms=exec_result.duration_ms
150
+ )
151
+
152
+ status = Status.VERIFIED if exec_result.is_success else Status.FAILED
153
+ evidence_type = EvidenceType.BUILD_LOG if is_build else EvidenceType.TEST_RUN
154
+
155
+ summary_text = (
156
+ f"{'Build' if is_build else 'Test'} suite '{suite_name}' {status.value} (exit code {exec_result.exit_code}) "
157
+ f"in {exec_result.duration_ms:.2f}ms. "
158
+ f"Results: {parsed_summary.passed} passed, {parsed_summary.failed} failed, {parsed_summary.errors} errors."
159
+ )
160
+
161
+ raw_payload = {
162
+ "suite_name": suite_name,
163
+ "is_build": is_build,
164
+ "command": exec_result.command,
165
+ "status": status.value,
166
+ "exit_code": exec_result.exit_code,
167
+ "duration_ms": exec_result.duration_ms,
168
+ "timed_out": exec_result.timed_out,
169
+ "error": exec_result.error,
170
+ "summary": {
171
+ "framework": parsed_summary.framework,
172
+ "total": parsed_summary.total,
173
+ "passed": parsed_summary.passed,
174
+ "failed": parsed_summary.failed,
175
+ "errors": parsed_summary.errors,
176
+ "skipped": parsed_summary.skipped,
177
+ },
178
+ "failure_details": parsed_summary.failure_details,
179
+ "stdout_snippet": exec_result.stdout[-1500:] if exec_result.stdout else "",
180
+ "stderr_snippet": exec_result.stderr[-1500:] if exec_result.stderr else ""
181
+ }
182
+
183
+ evidence = self.create_evidence(
184
+ evidence_type=evidence_type,
185
+ summary=summary_text,
186
+ raw_payload=raw_payload,
187
+ source_uri=str(root),
188
+ locator=f"{'build' if is_build else 'test'}:{suite_name}"
189
+ )
190
+
191
+ return exec_result, parsed_summary, evidence
192
+
193
+ def extract(self, target_path_or_input: str, context: Optional[Dict[str, Any]] = None) -> List[Evidence]:
194
+ """
195
+ Standard extract method: If context contains 'explicit_commands', executes them.
196
+ Otherwise, extracts discovery metadata only.
197
+ """
198
+ root_path = Path(target_path_or_input).resolve()
199
+ evidence_list: List[Evidence] = []
200
+
201
+ if context and "commands" in context:
202
+ for cmd_info in context["commands"]:
203
+ cmd = cmd_info.get("command")
204
+ suite = cmd_info.get("suite", "verification")
205
+ is_build = cmd_info.get("is_build", False)
206
+ if cmd:
207
+ _, _, ev = self.run_verification(root_path, cmd, suite_name=suite, is_build=is_build)
208
+ evidence_list.append(ev)
209
+ else:
210
+ # Only record discovery of available configurations
211
+ configs = self.discover_verification_configs(root_path)
212
+ evidence_list.append(self.create_evidence(
213
+ evidence_type=EvidenceType.TEST_RUN,
214
+ summary=f"Discovered {len(configs)} available verification and build configurations (auto-execution disabled)",
215
+ raw_payload={"discovered_configs": configs},
216
+ source_uri=str(root_path),
217
+ locator="verification:discovery"
218
+ ))
219
+
220
+ return evidence_list
221
+
222
+ def populate_project_state(
223
+ self,
224
+ root_dir: str,
225
+ canonical_state: CanonicalProjectState,
226
+ evidence_list: Optional[List[Evidence]] = None
227
+ ) -> ProjectState:
228
+ """
229
+ Registers verification evidence and updates ProjectState.test_results and build_status.
230
+ """
231
+ root_path = Path(root_dir).resolve()
232
+ ev_list = evidence_list or self.extract(str(root_path))
233
+
234
+ test_results: List[TestResult] = []
235
+ build_status = canonical_state.project_state.build_status
236
+
237
+ for ev in ev_list:
238
+ canonical_state.add_evidence(ev)
239
+
240
+ if ev.type == EvidenceType.TEST_RUN and "exit_code" in ev.raw_payload:
241
+ suite = ev.raw_payload.get("suite_name", "test_suite")
242
+ status = Status(ev.raw_payload.get("status", Status.FAILED.value))
243
+ tr = TestResult(
244
+ test_id=f"tr_{ev.id}",
245
+ name=suite,
246
+ suite=suite,
247
+ status=status,
248
+ exit_code=ev.raw_payload.get("exit_code", 1),
249
+ duration_ms=ev.raw_payload.get("duration_ms", 0.0),
250
+ output_snippet=ev.raw_payload.get("stdout_snippet", ""),
251
+ error_message=ev.raw_payload.get("error") or "\n".join(ev.raw_payload.get("failure_details", [])),
252
+ evidence_id=ev.id
253
+ )
254
+ test_results.append(tr)
255
+ elif ev.type == EvidenceType.BUILD_LOG and "exit_code" in ev.raw_payload:
256
+ build_status = Status(ev.raw_payload.get("status", Status.FAILED.value))
257
+
258
+ ps = canonical_state.project_state
259
+ ps.test_results.extend(test_results)
260
+ ps.build_status = build_status
261
+ ps.evidence_ids.extend([ev.id for ev in ev_list])
262
+
263
+ return ps
@@ -0,0 +1,221 @@
1
+ """
2
+ Project Continuum - Workspace & Source Code Evidence Extractor
3
+ ==============================================================
4
+ Milestone 2 - Phase 1: Physical Workspace & Source Code Inspection.
5
+ Scans the project directory, classifies files, detects programming languages,
6
+ runs language parsers to extract AST symbols and TODO markers, handles malformed
7
+ files gracefully, and produces canonical Evidence records.
8
+ """
9
+
10
+ from collections import Counter
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Set
13
+
14
+ from core.enums import EvidenceType, Status
15
+ from core.evidence import Evidence
16
+ from core.state_models import (
17
+ AstSymbol,
18
+ CanonicalProjectState,
19
+ ProjectState,
20
+ )
21
+ from extractors.base import (
22
+ CONFIG_FILE_NAMES,
23
+ DEFAULT_IGNORE_DIRS,
24
+ DOC_EXTENSIONS,
25
+ BaseEvidenceExtractor,
26
+ )
27
+ from extractors.parsers.base import ILanguageParser, ParserResult
28
+ from extractors.parsers.js_ts_parser import JavaScriptTypeScriptParser
29
+ from extractors.parsers.python_parser import PythonParser
30
+
31
+
32
+ class WorkspaceEvidenceExtractor(BaseEvidenceExtractor):
33
+ """
34
+ Primary extractor for Phase 1.
35
+ Performs physical filesystem scanning, language detection, and symbol parsing.
36
+ """
37
+
38
+ def __init__(self, custom_parsers: Optional[List[ILanguageParser]] = None):
39
+ # Register default language parsers
40
+ self.parsers: Dict[str, ILanguageParser] = {}
41
+ default_parsers = [PythonParser(), JavaScriptTypeScriptParser()]
42
+ if custom_parsers:
43
+ default_parsers.extend(custom_parsers)
44
+
45
+ for parser in default_parsers:
46
+ for ext in parser.supported_extensions:
47
+ self.parsers[ext.lower()] = parser
48
+
49
+ @property
50
+ def extractor_name(self) -> str:
51
+ return "WorkspaceEvidenceExtractor"
52
+
53
+ @property
54
+ def supported_evidence_types(self) -> List[EvidenceType]:
55
+ return [
56
+ EvidenceType.SOURCE_CODE,
57
+ EvidenceType.AST_SYMBOL,
58
+ EvidenceType.DOCUMENTATION,
59
+ EvidenceType.CONFIG_FILE
60
+ ]
61
+
62
+ def can_extract(self, target_path_or_input: str) -> bool:
63
+ path = Path(target_path_or_input)
64
+ return path.exists() and path.is_dir()
65
+
66
+ def extract(self, target_path_or_input: str, context: Optional[Dict[str, Any]] = None) -> List[Evidence]:
67
+ """
68
+ Scans workspace directory, parses files, and returns a list of canonical Evidence records.
69
+ """
70
+ root_path = Path(target_path_or_input).resolve()
71
+ evidence_list: List[Evidence] = []
72
+
73
+ scanned_files, lang_counts = self._scan_directory(root_path)
74
+
75
+ # 1. Workspace Structure Evidence
76
+ structure_payload = {
77
+ "root_path": str(root_path),
78
+ "total_files": len(scanned_files),
79
+ "languages": dict(lang_counts),
80
+ "file_list": [str(p.relative_to(root_path).as_posix()) for p in scanned_files]
81
+ }
82
+ evidence_list.append(self.create_evidence(
83
+ evidence_type=EvidenceType.SOURCE_CODE,
84
+ summary=f"Workspace structure scanned: {len(scanned_files)} files across {len(lang_counts)} languages",
85
+ raw_payload=structure_payload,
86
+ source_uri=str(root_path),
87
+ locator="workspace:root"
88
+ ))
89
+
90
+ # 2. Parse Source Code Files & Generate Symbol Evidence
91
+ for file_path in scanned_files:
92
+ ext = file_path.suffix.lower()
93
+ rel_path = file_path.relative_to(root_path).as_posix()
94
+
95
+ if ext in self.parsers:
96
+ parser = self.parsers[ext]
97
+ try:
98
+ content = file_path.read_text(encoding="utf-8", errors="replace")
99
+ except Exception as e:
100
+ continue
101
+
102
+ parse_result = parser.parse_source(rel_path, content)
103
+
104
+ # Record AST Symbol evidence
105
+ if parse_result.symbols:
106
+ evidence_list.append(self.create_evidence(
107
+ evidence_type=EvidenceType.AST_SYMBOL,
108
+ summary=f"Extracted {len(parse_result.symbols)} symbols from {rel_path}",
109
+ raw_payload={
110
+ "file_path": rel_path,
111
+ "language": parse_result.language,
112
+ "symbols": [s.to_dict() for s in parse_result.symbols],
113
+ "imports": [i.to_dict() for i in parse_result.imports],
114
+ "exported_symbols": parse_result.exported_symbols,
115
+ "markers": [m.to_dict() for m in parse_result.markers],
116
+ "errors": [e.to_dict() for e in parse_result.errors]
117
+ },
118
+ source_uri=rel_path,
119
+ locator=f"file:{rel_path}"
120
+ ))
121
+ # Record Documentation Evidence
122
+ elif ext in DOC_EXTENSIONS:
123
+ try:
124
+ content = file_path.read_text(encoding="utf-8", errors="replace")
125
+ evidence_list.append(self.create_evidence(
126
+ evidence_type=EvidenceType.DOCUMENTATION,
127
+ summary=f"Documentation file {rel_path} ({len(content.splitlines())} lines)",
128
+ raw_payload={
129
+ "file_path": rel_path,
130
+ "content": content
131
+ },
132
+ source_uri=rel_path,
133
+ locator=f"doc:{rel_path}"
134
+ ))
135
+ except Exception:
136
+ pass
137
+
138
+ return evidence_list
139
+
140
+ def populate_project_state(self, root_dir: str, canonical_state: CanonicalProjectState) -> ProjectState:
141
+ """
142
+ High-level convenience method: scans workspace, updates `canonical_state.project_state`,
143
+ and registers all harvested evidence into `canonical_state.evidence_pool`.
144
+ """
145
+ root_path = Path(root_dir).resolve()
146
+ evidence_list = self.extract(str(root_path))
147
+
148
+ all_symbols: List[AstSymbol] = []
149
+ all_markers: List[Dict[str, Any]] = []
150
+ scanned_files: List[str] = []
151
+ lang_counter = Counter()
152
+
153
+ for ev in evidence_list:
154
+ canonical_state.add_evidence(ev)
155
+
156
+ if ev.type == EvidenceType.SOURCE_CODE:
157
+ scanned_files = ev.raw_payload.get("file_list", [])
158
+ for lang, count in ev.raw_payload.get("languages", {}).items():
159
+ lang_counter[lang] += count
160
+ elif ev.type == EvidenceType.AST_SYMBOL:
161
+ for s_dict in ev.raw_payload.get("symbols", []):
162
+ sym = AstSymbol.from_dict(s_dict)
163
+ sym.evidence_id = ev.id
164
+ all_symbols.append(sym)
165
+ all_markers.extend(ev.raw_payload.get("markers", []))
166
+
167
+ # Update ProjectState
168
+ ps = canonical_state.project_state
169
+ ps.root_path = str(root_path)
170
+ ps.files = scanned_files
171
+ ps.detected_languages = [lang for lang, _ in lang_counter.most_common()]
172
+ ps.symbols = all_symbols
173
+ ps.todo_markers = all_markers
174
+ ps.evidence_ids = [ev.id for ev in evidence_list]
175
+
176
+ return ps
177
+
178
+ def _scan_directory(self, root_path: Path) -> tuple[List[Path], Counter]:
179
+ """Recursively traverses directory ignoring standard irrelevant folders."""
180
+ scanned_files: List[Path] = []
181
+ lang_counter = Counter()
182
+
183
+ for path in sorted(root_path.rglob("*")):
184
+ if path.is_file():
185
+ # Check if any parent part is in ignored list
186
+ parts = set(path.relative_to(root_path).parts)
187
+ if any(ignored in parts for ignored in DEFAULT_IGNORE_DIRS):
188
+ continue
189
+
190
+ scanned_files.append(path)
191
+ ext = path.suffix.lower()
192
+ lang = self._classify_extension(ext, path.name)
193
+ if lang:
194
+ lang_counter[lang] += 1
195
+
196
+ return scanned_files, lang_counter
197
+
198
+ def _classify_extension(self, ext: str, filename: str) -> Optional[str]:
199
+ if filename in CONFIG_FILE_NAMES:
200
+ return "Configuration"
201
+ if ext in {".py", ".pyw"}:
202
+ return "Python"
203
+ if ext in {".js", ".jsx", ".mjs", ".cjs"}:
204
+ return "JavaScript"
205
+ if ext in {".ts", ".tsx"}:
206
+ return "TypeScript"
207
+ if ext in {".go"}:
208
+ return "Go"
209
+ if ext in {".rs"}:
210
+ return "Rust"
211
+ if ext in {".java"}:
212
+ return "Java"
213
+ if ext in {".c", ".h"}:
214
+ return "C"
215
+ if ext in {".cpp", ".hpp", ".cc"}:
216
+ return "C++"
217
+ if ext in {".dart"}:
218
+ return "Dart"
219
+ if ext in DOC_EXTENSIONS:
220
+ return "Documentation"
221
+ return None
graph/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ Project Continuum - State Graph Subsystem Package
3
+ =================================================
4
+ Milestone 4 - Phases 9, 10 & 11.
5
+ """
6
+
7
+ from graph.models import GraphStats, GraphValidationResult, PropagationResult
8
+ from graph.snapshot import GraphSnapshot
9
+ from graph.diff import GraphDiff, NodeDelta
10
+ from graph.query import GraphQueryEngine
11
+ from graph.propagator import DependencyPropagator
12
+ from graph.manager import StateGraphManager
13
+
14
+ __all__ = [
15
+ "GraphStats",
16
+ "GraphValidationResult",
17
+ "PropagationResult",
18
+ "GraphSnapshot",
19
+ "GraphDiff",
20
+ "NodeDelta",
21
+ "GraphQueryEngine",
22
+ "DependencyPropagator",
23
+ "StateGraphManager",
24
+ ]
graph/diff.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ Project Continuum - Graph Comparison & Semantic Diff Engine
3
+ ===========================================================
4
+ Milestone 4 - Phase 11: Graph Persistence, Querying & Visualization.
5
+ Compares two graph snapshots or StateGraphManager instances to compute fine-grained diffs.
6
+ """
7
+
8
+ from dataclasses import dataclass, field, asdict
9
+ from typing import Any, Dict, List, Optional, Set, Tuple
10
+
11
+ from graph.snapshot import GraphSnapshot
12
+
13
+
14
+ @dataclass
15
+ class NodeDelta:
16
+ """Detailed attribute changes for a modified graph node."""
17
+ old_status: Optional[str] = None
18
+ new_status: Optional[str] = None
19
+ old_confidence: Optional[float] = None
20
+ new_confidence: Optional[float] = None
21
+ old_evidence_count: int = 0
22
+ new_evidence_count: int = 0
23
+
24
+ def to_dict(self) -> Dict[str, Any]:
25
+ return asdict(self)
26
+
27
+
28
+ @dataclass
29
+ class GraphDiff:
30
+ """
31
+ Semantic difference between two graph snapshots (Baseline -> Target).
32
+ """
33
+ baseline_id: str
34
+ target_id: str
35
+ added_nodes: List[str] = field(default_factory=list)
36
+ removed_nodes: List[str] = field(default_factory=list)
37
+ modified_nodes: Dict[str, NodeDelta] = field(default_factory=dict)
38
+ added_edges: List[Dict[str, str]] = field(default_factory=list)
39
+ removed_edges: List[Dict[str, str]] = field(default_factory=list)
40
+ has_changes: bool = False
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ return {
44
+ "baseline_id": self.baseline_id,
45
+ "target_id": self.target_id,
46
+ "has_changes": self.has_changes,
47
+ "added_nodes": self.added_nodes,
48
+ "removed_nodes": self.removed_nodes,
49
+ "modified_nodes": {k: v.to_dict() for k, v in self.modified_nodes.items()},
50
+ "added_edges": self.added_edges,
51
+ "removed_edges": self.removed_edges,
52
+ }
53
+
54
+ @classmethod
55
+ def compare(cls, snap_a: GraphSnapshot, snap_b: GraphSnapshot) -> "GraphDiff":
56
+ """
57
+ Computes the semantic delta from snap_a to snap_b.
58
+ """
59
+ nodes_a = {n["id"]: n for n in snap_a.nodes}
60
+ nodes_b = {n["id"]: n for n in snap_b.nodes}
61
+
62
+ ids_a = set(nodes_a.keys())
63
+ ids_b = set(nodes_b.keys())
64
+
65
+ added_node_ids = sorted(list(ids_b - ids_a))
66
+ removed_node_ids = sorted(list(ids_a - ids_b))
67
+ common_node_ids = ids_a & ids_b
68
+
69
+ modified_nodes: Dict[str, NodeDelta] = {}
70
+ for nid in sorted(list(common_node_ids)):
71
+ na = nodes_a[nid]
72
+ nb = nodes_b[nid]
73
+
74
+ stat_changed = na.get("status") != nb.get("status")
75
+ conf_changed = na.get("confidence_score") != nb.get("confidence_score")
76
+ ev_changed = len(na.get("evidence_ids", [])) != len(nb.get("evidence_ids", []))
77
+
78
+ if stat_changed or conf_changed or ev_changed:
79
+ modified_nodes[nid] = NodeDelta(
80
+ old_status=na.get("status"),
81
+ new_status=nb.get("status"),
82
+ old_confidence=na.get("confidence_score"),
83
+ new_confidence=nb.get("confidence_score"),
84
+ old_evidence_count=len(na.get("evidence_ids", [])),
85
+ new_evidence_count=len(nb.get("evidence_ids", []))
86
+ )
87
+
88
+ # Compare edges
89
+ edges_a = {(e["source_id"], e["target_id"], e["relation"]) for e in snap_a.edges}
90
+ edges_b = {(e["source_id"], e["target_id"], e["relation"]) for e in snap_b.edges}
91
+
92
+ added_edges_raw = edges_b - edges_a
93
+ removed_edges_raw = edges_a - edges_b
94
+
95
+ added_edges = [{"source_id": s, "target_id": t, "relation": r} for s, t, r in sorted(list(added_edges_raw))]
96
+ removed_edges = [{"source_id": s, "target_id": t, "relation": r} for s, t, r in sorted(list(removed_edges_raw))]
97
+
98
+ has_changes = bool(added_node_ids or removed_node_ids or modified_nodes or added_edges or removed_edges)
99
+
100
+ return cls(
101
+ baseline_id=snap_a.snapshot_id,
102
+ target_id=snap_b.snapshot_id,
103
+ added_nodes=added_node_ids,
104
+ removed_nodes=removed_node_ids,
105
+ modified_nodes=modified_nodes,
106
+ added_edges=added_edges,
107
+ removed_edges=removed_edges,
108
+ has_changes=has_changes
109
+ )