viveka-engine 0.1.2__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 (120) hide show
  1. viveka/__init__.py +23 -0
  2. viveka/capabilities/__init__.py +51 -0
  3. viveka/capabilities/boundaries.py +146 -0
  4. viveka/capabilities/classifier.py +440 -0
  5. viveka/capabilities/graph.py +265 -0
  6. viveka/capabilities/models.py +235 -0
  7. viveka/capabilities/rules.py +470 -0
  8. viveka/capabilities/vocabulary.py +125 -0
  9. viveka/cli/__init__.py +1 -0
  10. viveka/cli/app.py +138 -0
  11. viveka/cli/commands/__init__.py +1 -0
  12. viveka/cli/commands/demo.py +183 -0
  13. viveka/cli/commands/diagnose.py +156 -0
  14. viveka/cli/commands/doctor.py +211 -0
  15. viveka/cli/commands/evaluate.py +146 -0
  16. viveka/cli/commands/init_.py +106 -0
  17. viveka/cli/commands/inspect.py +362 -0
  18. viveka/cli/commands/properties.py +356 -0
  19. viveka/cli/commands/reduce.py +165 -0
  20. viveka/cli/commands/regression.py +299 -0
  21. viveka/cli/commands/replay.py +132 -0
  22. viveka/cli/commands/suggest.py +256 -0
  23. viveka/cli/commands/verify.py +263 -0
  24. viveka/cli/commands/version.py +21 -0
  25. viveka/cli/commands/worlds.py +197 -0
  26. viveka/core/__init__.py +1 -0
  27. viveka/core/config.py +295 -0
  28. viveka/core/errors.py +79 -0
  29. viveka/core/ids.py +79 -0
  30. viveka/core/paths.py +76 -0
  31. viveka/demo/__init__.py +19 -0
  32. viveka/demo/agent.py +115 -0
  33. viveka/demo/tools.py +150 -0
  34. viveka/demo/vocabulary.py +17 -0
  35. viveka/diagnosis/__init__.py +25 -0
  36. viveka/diagnosis/engine.py +334 -0
  37. viveka/diagnosis/models.py +72 -0
  38. viveka/diagnosis/store.py +96 -0
  39. viveka/evaluation/__init__.py +68 -0
  40. viveka/evaluation/binding.py +39 -0
  41. viveka/evaluation/contracts.py +43 -0
  42. viveka/evaluation/evaluators.py +392 -0
  43. viveka/evaluation/exceptions.py +19 -0
  44. viveka/evaluation/models.py +152 -0
  45. viveka/evaluation/reproduction.py +170 -0
  46. viveka/evaluation/resolvers.py +65 -0
  47. viveka/evaluation/store.py +102 -0
  48. viveka/evaluation/trace.py +43 -0
  49. viveka/evaluation/vocabulary.py +26 -0
  50. viveka/inspection/__init__.py +46 -0
  51. viveka/inspection/analyzer.py +98 -0
  52. viveka/inspection/classify.py +280 -0
  53. viveka/inspection/frameworks.py +125 -0
  54. viveka/inspection/ignore.py +96 -0
  55. viveka/inspection/models.py +86 -0
  56. viveka/inspection/python_ast.py +386 -0
  57. viveka/inspection/python_models.py +230 -0
  58. viveka/inspection/scanner.py +497 -0
  59. viveka/inspection/symbols.py +236 -0
  60. viveka/properties/__init__.py +67 -0
  61. viveka/properties/engine.py +376 -0
  62. viveka/properties/models.py +199 -0
  63. viveka/properties/rules.py +152 -0
  64. viveka/properties/store.py +150 -0
  65. viveka/properties/vocabulary.py +79 -0
  66. viveka/reasoning/__init__.py +28 -0
  67. viveka/reasoning/advisors/__init__.py +15 -0
  68. viveka/reasoning/advisors/diagnosis_enricher.py +106 -0
  69. viveka/reasoning/advisors/property_advisor.py +257 -0
  70. viveka/reasoning/advisors/world_advisor.py +187 -0
  71. viveka/reasoning/budget.py +57 -0
  72. viveka/reasoning/factory.py +113 -0
  73. viveka/reasoning/fake.py +92 -0
  74. viveka/reasoning/ollama.py +128 -0
  75. viveka/reasoning/openai_compat.py +144 -0
  76. viveka/reasoning/prompts.py +153 -0
  77. viveka/reasoning/provider.py +67 -0
  78. viveka/reasoning/schemas.py +150 -0
  79. viveka/reduction/__init__.py +42 -0
  80. viveka/reduction/engine.py +259 -0
  81. viveka/reduction/fingerprint.py +74 -0
  82. viveka/reduction/models.py +96 -0
  83. viveka/reduction/operators.py +145 -0
  84. viveka/reduction/runner.py +65 -0
  85. viveka/reduction/store.py +97 -0
  86. viveka/reduction/vocabulary.py +31 -0
  87. viveka/regression/__init__.py +29 -0
  88. viveka/regression/engine.py +262 -0
  89. viveka/regression/fingerprint.py +63 -0
  90. viveka/regression/models.py +148 -0
  91. viveka/regression/replay.py +214 -0
  92. viveka/regression/store.py +109 -0
  93. viveka/reporting/__init__.py +1 -0
  94. viveka/reporting/console.py +103 -0
  95. viveka/reporting/junit.py +107 -0
  96. viveka/runtime/__init__.py +61 -0
  97. viveka/runtime/adapter.py +36 -0
  98. viveka/runtime/collector.py +109 -0
  99. viveka/runtime/factory.py +44 -0
  100. viveka/runtime/http_adapter.py +436 -0
  101. viveka/runtime/mapper.py +106 -0
  102. viveka/runtime/mcp_adapter.py +555 -0
  103. viveka/runtime/models.py +209 -0
  104. viveka/runtime/python_adapter.py +189 -0
  105. viveka/runtime/vocabulary.py +67 -0
  106. viveka/verification/__init__.py +29 -0
  107. viveka/verification/engine.py +468 -0
  108. viveka/verification/models.py +63 -0
  109. viveka/verification/vocabulary.py +30 -0
  110. viveka/worlds/__init__.py +63 -0
  111. viveka/worlds/generate.py +225 -0
  112. viveka/worlds/models.py +208 -0
  113. viveka/worlds/mutations.py +771 -0
  114. viveka/worlds/store.py +109 -0
  115. viveka/worlds/vocabulary.py +205 -0
  116. viveka_engine-0.1.2.dist-info/METADATA +216 -0
  117. viveka_engine-0.1.2.dist-info/RECORD +120 -0
  118. viveka_engine-0.1.2.dist-info/WHEEL +4 -0
  119. viveka_engine-0.1.2.dist-info/entry_points.txt +2 -0
  120. viveka_engine-0.1.2.dist-info/licenses/LICENSE +21 -0
viveka/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ VIVEKA · विवेक — Discernment
3
+ Property-based verification engine for AI agents.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+
11
+ def get_version() -> str:
12
+ """Return the installed package version from distribution metadata.
13
+
14
+ Falls back to "0.0.0+unknown" when the package is not formally installed
15
+ (e.g. during editable development without ``pip install -e .``).
16
+ """
17
+ try:
18
+ return version("viveka-engine")
19
+ except PackageNotFoundError:
20
+ return "0.0.0+unknown"
21
+
22
+
23
+ __version__ = get_version()
@@ -0,0 +1,51 @@
1
+ """
2
+ VIVEKA Phase 4 — Capability Model, Semantic Classification, and Trust Boundaries.
3
+
4
+ Public API:
5
+ - :func:`classify_capabilities` — classify Phase 3 output into capabilities.
6
+ - :func:`build_capability_graph` — build a static capability graph.
7
+ - :func:`infer_trust_boundaries` — infer trust boundaries from capabilities.
8
+ - :class:`CapabilityAnalysisResult` — top-level Phase 4 result model.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from viveka.capabilities.boundaries import infer_trust_boundaries
14
+ from viveka.capabilities.classifier import classify_capabilities
15
+ from viveka.capabilities.graph import build_capability_graph
16
+ from viveka.capabilities.models import (
17
+ Capability,
18
+ CapabilityAnalysisResult,
19
+ CapabilityEvidence,
20
+ CapabilityGraph,
21
+ CapabilityStatistics,
22
+ GraphEdge,
23
+ GraphNode,
24
+ TrustBoundary,
25
+ )
26
+ from viveka.capabilities.vocabulary import (
27
+ CapabilityTag,
28
+ Externality,
29
+ Reversibility,
30
+ SideEffect,
31
+ TrustRole,
32
+ )
33
+
34
+ __all__ = [
35
+ "Capability",
36
+ "CapabilityAnalysisResult",
37
+ "CapabilityEvidence",
38
+ "CapabilityGraph",
39
+ "CapabilityStatistics",
40
+ "CapabilityTag",
41
+ "Externality",
42
+ "GraphEdge",
43
+ "GraphNode",
44
+ "Reversibility",
45
+ "SideEffect",
46
+ "TrustBoundary",
47
+ "TrustRole",
48
+ "build_capability_graph",
49
+ "classify_capabilities",
50
+ "infer_trust_boundaries",
51
+ ]
@@ -0,0 +1,146 @@
1
+ """
2
+ Static trust boundary inference for VIVEKA Phase 4.
3
+
4
+ Infers :class:`~viveka.capabilities.models.TrustBoundary` objects from the
5
+ classified capabilities list. All boundaries are ``static_only = True``.
6
+
7
+ Four boundary types are inferred:
8
+ untrusted_ingress
9
+ A capability tagged as retrieval or untrusted_input feeds into the
10
+ agent context.
11
+
12
+ sensitive_source
13
+ A capability tagged as sensitive_read, secret_access, or database_read
14
+ is a source of privileged data.
15
+
16
+ privileged_sink
17
+ A capability tagged as financial_write, destructive_write,
18
+ shell_execution, or code_execution is a privileged action sink.
19
+
20
+ external_sink
21
+ A capability tagged as communication, external_write, or network_write
22
+ sends data outside the system boundary.
23
+
24
+ No LLMs, no network, no target code execution.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from viveka.capabilities.models import Capability, TrustBoundary
30
+ from viveka.capabilities.vocabulary import CapabilityTag
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Tag sets that define each boundary type
34
+ # ---------------------------------------------------------------------------
35
+
36
+ _UNTRUSTED_INGRESS_TAGS: frozenset[CapabilityTag] = frozenset(
37
+ {CapabilityTag.RETRIEVAL, CapabilityTag.UNTRUSTED_INPUT}
38
+ )
39
+
40
+ _SENSITIVE_SOURCE_TAGS: frozenset[CapabilityTag] = frozenset(
41
+ {CapabilityTag.SENSITIVE_READ, CapabilityTag.SECRET_ACCESS, CapabilityTag.DATABASE_READ}
42
+ )
43
+
44
+ _PRIVILEGED_SINK_TAGS: frozenset[CapabilityTag] = frozenset(
45
+ {
46
+ CapabilityTag.FINANCIAL_WRITE,
47
+ CapabilityTag.DESTRUCTIVE_WRITE,
48
+ CapabilityTag.SHELL_EXECUTION,
49
+ CapabilityTag.CODE_EXECUTION,
50
+ }
51
+ )
52
+
53
+ _EXTERNAL_SINK_TAGS: frozenset[CapabilityTag] = frozenset(
54
+ {
55
+ CapabilityTag.COMMUNICATION,
56
+ CapabilityTag.EXTERNAL_WRITE,
57
+ CapabilityTag.NETWORK_WRITE,
58
+ }
59
+ )
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Boundary ID counter (simple sequential for determinism in tests)
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _boundary_id(index: int) -> str:
68
+ return f"VBOUND-{index:04d}"
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Inference engine
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def infer_trust_boundaries(
77
+ capabilities: list[Capability],
78
+ ) -> list[TrustBoundary]:
79
+ """Infer trust boundaries from a list of capabilities.
80
+
81
+ Args:
82
+ capabilities: The Phase 4 capability list produced by the classifier.
83
+
84
+ Returns:
85
+ A list of :class:`TrustBoundary` objects (all ``static_only = True``).
86
+ """
87
+ boundaries: list[TrustBoundary] = []
88
+ counter = 0
89
+
90
+ for cap in capabilities:
91
+ tag_set = frozenset(cap.tags)
92
+ boundary_type: str | None = None
93
+
94
+ if tag_set & _UNTRUSTED_INGRESS_TAGS:
95
+ boundary_type = "untrusted_ingress"
96
+ elif tag_set & _SENSITIVE_SOURCE_TAGS:
97
+ boundary_type = "sensitive_source"
98
+ elif tag_set & _PRIVILEGED_SINK_TAGS:
99
+ boundary_type = "privileged_sink"
100
+ elif tag_set & _EXTERNAL_SINK_TAGS:
101
+ boundary_type = "external_sink"
102
+
103
+ if boundary_type is None:
104
+ continue
105
+
106
+ evidence_items = [
107
+ f"{e.evidence_type}:{e.value} @ {e.file_path}:{e.line}" for e in cap.evidence[:5]
108
+ ]
109
+
110
+ counter += 1
111
+ boundary = TrustBoundary(
112
+ id=_boundary_id(counter),
113
+ boundary_type=boundary_type,
114
+ source=_source_for(boundary_type, cap.source_symbol),
115
+ destination=_destination_for(boundary_type, cap.source_symbol),
116
+ confidence=cap.confidence,
117
+ evidence=evidence_items,
118
+ static_only=True,
119
+ )
120
+ boundaries.append(boundary)
121
+
122
+ return boundaries
123
+
124
+
125
+ def _source_for(boundary_type: str, symbol: str) -> str:
126
+ """Return the conceptual source of a trust boundary."""
127
+ if boundary_type == "untrusted_ingress":
128
+ return "external:untrusted_source"
129
+ if boundary_type == "sensitive_source":
130
+ return symbol
131
+ if boundary_type in ("privileged_sink", "external_sink"):
132
+ return "agent:context"
133
+ return symbol
134
+
135
+
136
+ def _destination_for(boundary_type: str, symbol: str) -> str:
137
+ """Return the conceptual destination of a trust boundary."""
138
+ if boundary_type == "untrusted_ingress":
139
+ return "agent:context"
140
+ if boundary_type == "sensitive_source":
141
+ return "agent:context"
142
+ if boundary_type == "privileged_sink":
143
+ return symbol
144
+ if boundary_type == "external_sink":
145
+ return symbol
146
+ return symbol
@@ -0,0 +1,440 @@
1
+ """
2
+ Static capability classifier for VIVEKA Phase 4.
3
+
4
+ Evaluates deterministic :class:`~viveka.capabilities.rules.CapabilityRule` s
5
+ against Phase 3 :class:`~viveka.inspection.python_models.StaticAnalysisResult`
6
+ to produce a list of :class:`~viveka.capabilities.models.Capability` objects.
7
+
8
+ Design constraints:
9
+ - No LLMs. No network. No target code execution.
10
+ - Matching is string-substring only — deliberate simplicity.
11
+ - ``unknown`` is always preferred over a wrong label.
12
+ - False-positive controls are baked in (see :func:`_is_false_positive`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections import defaultdict
19
+
20
+ from viveka.capabilities.models import Capability, CapabilityEvidence, CapabilityStatistics
21
+ from viveka.capabilities.rules import RULES, CapabilityRule
22
+ from viveka.capabilities.vocabulary import CapabilityTag
23
+ from viveka.core.ids import new_id
24
+ from viveka.inspection.python_models import (
25
+ PythonFunction,
26
+ PythonModule,
27
+ StaticAnalysisResult,
28
+ )
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # False-positive guard patterns
32
+ # ---------------------------------------------------------------------------
33
+
34
+ # Patterns whose presence in a *function name* strongly suggests the symbol
35
+ # is about documentation, policy, or test — NOT a live operational capability.
36
+ _FP_NAME_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
37
+ re.compile(p, re.IGNORECASE)
38
+ for p in [
39
+ r"(policy|document|doc|readme|label|text|string|template|example|fixture|dummy|mock|fake|stub|test_|_test)",
40
+ ]
41
+ )
42
+
43
+ # Call expressions that look like capability-relevant calls but are actually
44
+ # common non-capability uses when the symbol name suggests it's a string/label.
45
+ _FP_SAFE_CALL_FRAGMENTS: frozenset[str] = frozenset(
46
+ {
47
+ # Common string/text operations that aren't real capabilities
48
+ "str.",
49
+ "label",
50
+ "template",
51
+ "format",
52
+ "print",
53
+ "log",
54
+ "logger",
55
+ "logging",
56
+ }
57
+ )
58
+
59
+ # Environment variable patterns that are typically config reads, not secrets
60
+ _FP_ENV_SAFE_NAMES: frozenset[str] = frozenset(
61
+ {
62
+ "APP_ENV",
63
+ "ENVIRONMENT",
64
+ "ENV",
65
+ "DEBUG",
66
+ "LOG_LEVEL",
67
+ "LOG_FORMAT",
68
+ "PYTHONPATH",
69
+ "PATH",
70
+ "HOME",
71
+ "USER",
72
+ "LANG",
73
+ "LOCALE",
74
+ "TZ",
75
+ "TIMEZONE",
76
+ "PORT",
77
+ "HOST",
78
+ "WORKERS",
79
+ }
80
+ )
81
+
82
+ # Name fragments that, in isolation, are not enough to classify as "delete"
83
+ # without corroborating call evidence
84
+ _FP_DELETE_LABEL_NAMES: frozenset[str] = frozenset(
85
+ {
86
+ "delete_label",
87
+ "remove_tag",
88
+ "delete_tag",
89
+ "untag",
90
+ "remove_flag",
91
+ }
92
+ )
93
+
94
+ # Name fragments that suggest planning / description rather than execution
95
+ _FP_EXECUTION_PLAN_NAMES: frozenset[str] = frozenset(
96
+ {
97
+ "execute_plan",
98
+ "execution_plan",
99
+ "plan_executor",
100
+ "run_plan",
101
+ }
102
+ )
103
+
104
+
105
+ def _is_false_positive(
106
+ func: PythonFunction,
107
+ rule: CapabilityRule,
108
+ call_ev: list[CapabilityEvidence],
109
+ evidence_values: list[str],
110
+ ) -> bool:
111
+ """Return True if this (function, rule) pairing is a likely false positive.
112
+
113
+ Conservative: we only suppress when we have high confidence the match
114
+ is spurious. When uncertain, we keep the capability (prefer false
115
+ positives over false negatives for safety-oriented analysis).
116
+ """
117
+ fname = func.name.lower()
118
+ qname = func.qualified_name.lower()
119
+
120
+ # Policy / documentation functions — names that strongly suggest text content
121
+ for pat in _FP_NAME_PATTERNS:
122
+ if pat.search(fname):
123
+ # Only allow if rule is corroborated by a real, operational call pattern
124
+ call_values = [e.value for e in call_ev]
125
+ if not any(
126
+ cv
127
+ for cv in call_values
128
+ if not any(safe in cv.lower() for safe in _FP_SAFE_CALL_FRAGMENTS)
129
+ ):
130
+ return True
131
+
132
+ # Secret/env access: suppress if the env var name is a common config var
133
+ if rule.id == "RULE-SEC-ENV-001":
134
+ secret_looking = False
135
+ for call in func.calls:
136
+ callee = call.callee
137
+ for safe in _FP_ENV_SAFE_NAMES:
138
+ if safe in callee.upper():
139
+ pass
140
+ else:
141
+ secret_looking = True
142
+ if not secret_looking and not evidence_values:
143
+ return True
144
+
145
+ # Delete label / tag operations — name-only match without call evidence
146
+ if rule.id == "RULE-FS-DEL-001" and qname in _FP_DELETE_LABEL_NAMES:
147
+ call_values = [e.value for e in call_ev]
148
+ if not any(
149
+ pat in cv
150
+ for cv in call_values
151
+ for pat in (
152
+ "os.remove",
153
+ "os.unlink",
154
+ "shutil.rmtree",
155
+ "Path.unlink",
156
+ ".unlink(",
157
+ "rmtree",
158
+ )
159
+ ):
160
+ return True
161
+
162
+ # "execute_plan" style names — planning functions, not code execution
163
+ if rule.id == "RULE-PROC-EVAL-001" and qname in _FP_EXECUTION_PLAN_NAMES:
164
+ call_values = [e.value for e in call_ev]
165
+ if not any("eval(" in cv or "exec(" in cv for cv in call_values):
166
+ return True
167
+
168
+ return False
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Evidence extractors
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def _collect_call_evidence(
177
+ func: PythonFunction,
178
+ patterns: tuple[str, ...],
179
+ file_path: str,
180
+ ) -> list[CapabilityEvidence]:
181
+ """Extract CapabilityEvidence from calls inside ``func`` matching ``patterns``."""
182
+ evidence: list[CapabilityEvidence] = []
183
+ for call in func.calls:
184
+ for pat in patterns:
185
+ if pat.lower() in call.callee.lower():
186
+ evidence.append(
187
+ CapabilityEvidence(
188
+ evidence_type="call",
189
+ value=call.callee,
190
+ file_path=file_path,
191
+ line=call.line,
192
+ weight=0.9,
193
+ )
194
+ )
195
+ break # one evidence per call
196
+ return evidence
197
+
198
+
199
+ def _collect_import_evidence(
200
+ module: PythonModule,
201
+ patterns: tuple[str, ...],
202
+ ) -> list[CapabilityEvidence]:
203
+ """Extract CapabilityEvidence from module imports matching ``patterns``."""
204
+ evidence: list[CapabilityEvidence] = []
205
+ seen: set[str] = set()
206
+ for imp in module.imports:
207
+ for pat in patterns:
208
+ if pat.lower() in imp.module.lower() and imp.module not in seen:
209
+ seen.add(imp.module)
210
+ evidence.append(
211
+ CapabilityEvidence(
212
+ evidence_type="import",
213
+ value=imp.module,
214
+ file_path=module.path,
215
+ line=imp.line,
216
+ weight=0.6,
217
+ )
218
+ )
219
+ break
220
+ return evidence
221
+
222
+
223
+ def _collect_name_evidence(
224
+ func: PythonFunction,
225
+ patterns: tuple[str, ...],
226
+ file_path: str,
227
+ ) -> list[CapabilityEvidence]:
228
+ """Extract CapabilityEvidence from function name matching ``patterns``."""
229
+ evidence: list[CapabilityEvidence] = []
230
+ for pat in patterns:
231
+ if pat.lower() in func.name.lower():
232
+ evidence.append(
233
+ CapabilityEvidence(
234
+ evidence_type="name_match",
235
+ value=func.name,
236
+ file_path=file_path,
237
+ line=func.line_start,
238
+ weight=0.4,
239
+ )
240
+ )
241
+ break
242
+ return evidence
243
+
244
+
245
+ def _collect_decorator_evidence(
246
+ func: PythonFunction,
247
+ patterns: tuple[str, ...],
248
+ file_path: str,
249
+ ) -> list[CapabilityEvidence]:
250
+ """Extract CapabilityEvidence from function decorators matching ``patterns``."""
251
+ evidence: list[CapabilityEvidence] = []
252
+ for dec in func.decorators:
253
+ for pat in patterns:
254
+ if pat.lower() in dec.name.lower():
255
+ evidence.append(
256
+ CapabilityEvidence(
257
+ evidence_type="decorator",
258
+ value=dec.name,
259
+ file_path=file_path,
260
+ line=dec.line,
261
+ weight=0.7,
262
+ )
263
+ )
264
+ break
265
+ return evidence
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Confidence score
270
+ # ---------------------------------------------------------------------------
271
+
272
+ _CONFIDENCE_THRESHOLDS = {
273
+ "high": 0.7,
274
+ "medium": 0.35,
275
+ "low": 0.0,
276
+ }
277
+
278
+
279
+ def _score_to_confidence(score: float) -> str:
280
+ if score >= _CONFIDENCE_THRESHOLDS["high"]:
281
+ return "high"
282
+ if score >= _CONFIDENCE_THRESHOLDS["medium"]:
283
+ return "medium"
284
+ return "low"
285
+
286
+
287
+ def _evidence_score(evidence: list[CapabilityEvidence]) -> float:
288
+ if not evidence:
289
+ return 0.0
290
+ return min(1.0, sum(e.weight for e in evidence) / max(1, len(evidence)) + 0.1 * len(evidence))
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Function-level classification
295
+ # ---------------------------------------------------------------------------
296
+
297
+
298
+ def _classify_function(
299
+ func: PythonFunction,
300
+ module: PythonModule,
301
+ is_tool_candidate: bool,
302
+ ) -> list[Capability]:
303
+ """Classify a single function against all rules."""
304
+ capabilities: list[Capability] = []
305
+
306
+ for rule in RULES:
307
+ if rule.id == "RULE-FALLBACK-001":
308
+ continue # applied separately after all other rules
309
+
310
+ # Gather evidence for this rule against this function
311
+ call_ev = _collect_call_evidence(func, rule.call_patterns, module.path)
312
+ import_ev = _collect_import_evidence(module, rule.import_patterns)
313
+ name_ev = _collect_name_evidence(func, rule.name_patterns, module.path)
314
+ dec_ev = _collect_decorator_evidence(func, rule.decorator_patterns, module.path)
315
+
316
+ # A function must have function-level evidence (call, name, or decorator).
317
+ # Module-level imports alone cannot attribute a capability to an arbitrary function.
318
+ if not (call_ev or name_ev or dec_ev):
319
+ continue
320
+
321
+ all_evidence = call_ev + import_ev + name_ev + dec_ev
322
+ if not all_evidence:
323
+ continue
324
+
325
+ # False-positive guard
326
+ evidence_values = [e.value for e in all_evidence]
327
+ if _is_false_positive(func, rule, call_ev, evidence_values):
328
+ continue
329
+
330
+ score = _evidence_score(all_evidence)
331
+ final_confidence = _score_to_confidence(score)
332
+
333
+ cap = Capability(
334
+ id=new_id("VCAP"),
335
+ name=_capability_name(func.name, rule),
336
+ source_symbol=func.qualified_name,
337
+ source_file=module.path,
338
+ source_line=func.line_start,
339
+ tags=list(rule.produced_tags),
340
+ confidence=final_confidence,
341
+ evidence=all_evidence,
342
+ side_effect=rule.side_effect,
343
+ externality=rule.externality,
344
+ trust_role=rule.trust_role,
345
+ reversibility=rule.reversibility,
346
+ description=rule.explanation,
347
+ rule_id=rule.id,
348
+ )
349
+ capabilities.append(cap)
350
+
351
+ return capabilities
352
+
353
+
354
+ def _capability_name(func_name: str, rule: CapabilityRule) -> str:
355
+ """Derive a human-readable capability name from the function name and rule."""
356
+ # Use the primary tag label if available
357
+ if rule.produced_tags and rule.produced_tags[0] != CapabilityTag.UNKNOWN:
358
+ tag_label = str(rule.produced_tags[0]).replace("_", " ").title()
359
+ return f"{func_name} [{tag_label}]"
360
+ return f"{func_name} [Unknown]"
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # Repository-level classification
365
+ # ---------------------------------------------------------------------------
366
+
367
+
368
+ def classify_capabilities(
369
+ static_result: StaticAnalysisResult,
370
+ ) -> tuple[list[Capability], CapabilityStatistics]:
371
+ """Classify capabilities from a Phase 3 :class:`StaticAnalysisResult`.
372
+
373
+ Args:
374
+ static_result: The complete Phase 3 static analysis output.
375
+
376
+ Returns:
377
+ A tuple of ``(capabilities, statistics)``.
378
+ """
379
+ tool_candidate_names: frozenset[str] = frozenset(
380
+ tc.qualified_name for tc in static_result.tool_candidates
381
+ )
382
+
383
+ all_capabilities: list[Capability] = []
384
+
385
+ for module in static_result.modules:
386
+ if module.parse_status != "ok":
387
+ continue
388
+
389
+ # Collect all functions: top-level + class methods
390
+ functions: list[PythonFunction] = list(module.functions)
391
+ for cls in module.classes:
392
+ functions.extend(cls.methods)
393
+
394
+ for func in functions:
395
+ is_tool = func.qualified_name in tool_candidate_names
396
+ caps = _classify_function(func, module, is_tool)
397
+ all_capabilities.extend(caps)
398
+
399
+ # Deduplicate: same (symbol, rule_id) pair — keep highest-confidence one
400
+ deduped = _deduplicate(all_capabilities)
401
+
402
+ statistics = _compute_statistics(deduped)
403
+ return deduped, statistics
404
+
405
+
406
+ def _deduplicate(caps: list[Capability]) -> list[Capability]:
407
+ """Remove duplicate (source_symbol, rule_id) capability pairs.
408
+
409
+ When duplicates exist, keep the one with the highest confidence.
410
+ """
411
+ _conf_rank = {"high": 2, "medium": 1, "low": 0}
412
+ seen: dict[tuple[str, str | None], Capability] = {}
413
+ for cap in caps:
414
+ key = (cap.source_symbol, cap.rule_id)
415
+ existing = seen.get(key)
416
+ if existing is None or _conf_rank.get(cap.confidence, 0) > _conf_rank.get(
417
+ existing.confidence, 0
418
+ ):
419
+ seen[key] = cap
420
+ return list(seen.values())
421
+
422
+
423
+ def _compute_statistics(caps: list[Capability]) -> CapabilityStatistics:
424
+ """Compute aggregate statistics from a list of capabilities."""
425
+ by_tag: dict[str, int] = defaultdict(int)
426
+ by_side_effect: dict[str, int] = defaultdict(int)
427
+ by_trust_role: dict[str, int] = defaultdict(int)
428
+
429
+ for cap in caps:
430
+ for tag in cap.tags:
431
+ by_tag[str(tag)] += 1
432
+ by_side_effect[str(cap.side_effect)] += 1
433
+ by_trust_role[str(cap.trust_role)] += 1
434
+
435
+ return CapabilityStatistics(
436
+ total_capabilities=len(caps),
437
+ by_tag=dict(by_tag),
438
+ by_side_effect=dict(by_side_effect),
439
+ by_trust_role=dict(by_trust_role),
440
+ )