cfa-kernel 0.1.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 (98) hide show
  1. cfa/__init__.py +39 -0
  2. cfa/_lazy.py +39 -0
  3. cfa/adapters/__init__.py +104 -0
  4. cfa/adapters/autogen.py +19 -0
  5. cfa/adapters/crewai.py +19 -0
  6. cfa/adapters/dspy.py +19 -0
  7. cfa/adapters/langgraph.py +19 -0
  8. cfa/adapters/openai_agents.py +19 -0
  9. cfa/audit/__init__.py +15 -0
  10. cfa/audit/context.py +205 -0
  11. cfa/audit/hashing.py +41 -0
  12. cfa/audit/trail.py +194 -0
  13. cfa/backends/__init__.py +132 -0
  14. cfa/backends/dbt.py +338 -0
  15. cfa/backends/pyspark.py +240 -0
  16. cfa/backends/sql.py +270 -0
  17. cfa/behavior/__init__.py +49 -0
  18. cfa/behavior/llm.py +244 -0
  19. cfa/behavior/spec.py +235 -0
  20. cfa/behavior/systematizer.py +222 -0
  21. cfa/cli/__init__.py +296 -0
  22. cfa/cli/__main__.py +6 -0
  23. cfa/cli/_helpers.py +109 -0
  24. cfa/cli/core/__init__.py +0 -0
  25. cfa/cli/core/evaluate.py +72 -0
  26. cfa/cli/core/validate.py +29 -0
  27. cfa/cli/formatters.py +280 -0
  28. cfa/cli/governance/__init__.py +0 -0
  29. cfa/cli/governance/audit.py +65 -0
  30. cfa/cli/governance/catalog.py +28 -0
  31. cfa/cli/governance/policy.py +119 -0
  32. cfa/cli/governance/rules.py +42 -0
  33. cfa/cli/governance/signature.py +31 -0
  34. cfa/cli/infrastructure/__init__.py +0 -0
  35. cfa/cli/infrastructure/backend_list.py +24 -0
  36. cfa/cli/infrastructure/storage.py +87 -0
  37. cfa/cli/project/__init__.py +0 -0
  38. cfa/cli/project/init.py +73 -0
  39. cfa/cli/project/lifecycle.py +92 -0
  40. cfa/cli/project/status.py +75 -0
  41. cfa/cli/project/taxonomy.py +38 -0
  42. cfa/cli/reporting/__init__.py +0 -0
  43. cfa/cli/reporting/report.py +109 -0
  44. cfa/cli/reporting/serve.py +43 -0
  45. cfa/config.py +103 -0
  46. cfa/core/__init__.py +19 -0
  47. cfa/core/codegen.py +65 -0
  48. cfa/core/conditions.py +129 -0
  49. cfa/core/kernel.py +224 -0
  50. cfa/core/phases/__init__.py +0 -0
  51. cfa/core/phases/runner.py +477 -0
  52. cfa/core/planner.py +290 -0
  53. cfa/execution/__init__.py +12 -0
  54. cfa/execution/partial.py +339 -0
  55. cfa/execution/state_projection.py +216 -0
  56. cfa/governance/__init__.py +76 -0
  57. cfa/lifecycle/__init__.py +51 -0
  58. cfa/mcp/__init__.py +347 -0
  59. cfa/mcp/__main__.py +4 -0
  60. cfa/normalizer/__init__.py +15 -0
  61. cfa/normalizer/base.py +441 -0
  62. cfa/normalizer/llm.py +426 -0
  63. cfa/observability/__init__.py +14 -0
  64. cfa/observability/indices.py +177 -0
  65. cfa/observability/metrics.py +91 -0
  66. cfa/observability/notify.py +79 -0
  67. cfa/observability/otel.py +81 -0
  68. cfa/observability/promotion.py +367 -0
  69. cfa/policy/__init__.py +12 -0
  70. cfa/policy/bundle.py +317 -0
  71. cfa/policy/catalog.py +117 -0
  72. cfa/policy/engine.py +306 -0
  73. cfa/reporting/__init__.py +42 -0
  74. cfa/reporting/charts.py +223 -0
  75. cfa/reporting/engine.py +456 -0
  76. cfa/resolution/__init__.py +62 -0
  77. cfa/runtime/__init__.py +13 -0
  78. cfa/runtime/gate.py +287 -0
  79. cfa/sandbox/__init__.py +189 -0
  80. cfa/sandbox/executor.py +92 -0
  81. cfa/sandbox/mock.py +89 -0
  82. cfa/sandbox/panic.py +52 -0
  83. cfa/storage/__init__.py +591 -0
  84. cfa/testing/__init__.py +60 -0
  85. cfa/testing/asserts.py +77 -0
  86. cfa/testing/evaluate.py +168 -0
  87. cfa/testing/fixtures.py +89 -0
  88. cfa/testing/markers.py +36 -0
  89. cfa/types.py +489 -0
  90. cfa/validation/__init__.py +14 -0
  91. cfa/validation/runtime.py +285 -0
  92. cfa/validation/signature.py +146 -0
  93. cfa/validation/static.py +252 -0
  94. cfa_kernel-0.1.0.dist-info/METADATA +32 -0
  95. cfa_kernel-0.1.0.dist-info/RECORD +98 -0
  96. cfa_kernel-0.1.0.dist-info/WHEEL +4 -0
  97. cfa_kernel-0.1.0.dist-info/entry_points.txt +3 -0
  98. cfa_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,91 @@
1
+ """
2
+ CFA Prometheus Metrics
3
+ ======================
4
+ Optional Prometheus metrics exposition for production monitoring.
5
+
6
+ Exposes counters and gauges for:
7
+ - Policy evaluations (total by decision)
8
+ - Replan attempts
9
+ - Audit trail events and chain integrity
10
+ - Lifecycle indices (per pipeline)
11
+
12
+ Usage:
13
+ from cfa.observability.metrics import get_metrics_text
14
+ print(get_metrics_text()) # Prometheus text format
15
+
16
+ # Or: cfa serve --metrics-port 9090
17
+ # → http://localhost:9090/metrics
18
+
19
+ Zero dependencies in core — uses plain text format.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import threading
25
+
26
+ # In-memory metric counters
27
+ _COUNTERS: dict[str, int] = {}
28
+ _GAUGES: dict[str, float] = {}
29
+ _LOCK = threading.Lock()
30
+
31
+
32
+ def inc_counter(name: str, value: int = 1, labels: dict[str, str] | None = None) -> None:
33
+ key = _metric_key(name, labels)
34
+ with _LOCK:
35
+ _COUNTERS[key] = _COUNTERS.get(key, 0) + value
36
+
37
+
38
+ def set_gauge(name: str, value: float, labels: dict[str, str] | None = None) -> None:
39
+ key = _metric_key(name, labels)
40
+ with _LOCK:
41
+ _GAUGES[key] = value
42
+
43
+
44
+ def _metric_key(name: str, labels: dict[str, str] | None) -> str:
45
+ if not labels:
46
+ return name
47
+ label_str = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
48
+ return f"{name}{{{label_str}}}"
49
+
50
+
51
+ def record_policy_evaluation(decision: str) -> None:
52
+ inc_counter("cfa_policy_evaluations_total", labels={"decision": decision})
53
+
54
+
55
+ def record_replan() -> None:
56
+ inc_counter("cfa_replan_attempts_total")
57
+
58
+
59
+ def record_audit_event(outcome: str = "ok") -> None:
60
+ inc_counter("cfa_audit_events_total")
61
+
62
+
63
+ def record_lifecycle_index(pipeline_hash: str, index_name: str, value: float) -> None:
64
+ set_gauge("cfa_lifecycle_index", value, labels={"pipeline": pipeline_hash[:12], "index": index_name})
65
+
66
+
67
+ def get_metrics_text() -> str:
68
+ with _LOCK:
69
+ counters = dict(_COUNTERS)
70
+ gauges = dict(_GAUGES)
71
+ lines: list[str] = []
72
+ lines.append("# HELP cfa_policy_evaluations_total Total CFA policy evaluations by decision.")
73
+ lines.append("# TYPE cfa_policy_evaluations_total counter")
74
+ for key, val in counters.items():
75
+ if key.startswith("cfa_policy_evaluations"):
76
+ lines.append(f"{key} {val}")
77
+ lines.append("# HELP cfa_replan_attempts_total Total CFA replan attempts.")
78
+ lines.append("# TYPE cfa_replan_attempts_total counter")
79
+ for key, val in counters.items():
80
+ if key.startswith("cfa_replan"):
81
+ lines.append(f"{key} {val}")
82
+ lines.append("# HELP cfa_audit_events_total Total audit events recorded.")
83
+ lines.append("# TYPE cfa_audit_events_total counter")
84
+ for key, val in counters.items():
85
+ if key.startswith("cfa_audit"):
86
+ lines.append(f"{key} {val}")
87
+ lines.append("# HELP cfa_lifecycle_index Current lifecycle index value per pipeline.")
88
+ lines.append("# TYPE cfa_lifecycle_index gauge")
89
+ for key, val in gauges.items():
90
+ lines.append(f"{key} {val}")
91
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,79 @@
1
+ """
2
+ CFA Notifications
3
+ =================
4
+ Webhook-based notifications for governance decisions.
5
+
6
+ Sends formatted messages to Slack, Teams, or generic webhooks
7
+ when a policy evaluation results in BLOCK or REPLAN.
8
+
9
+ Usage:
10
+ from cfa.observability.notify import SlackNotifier
11
+
12
+ notifier = SlackNotifier(webhook_url="https://hooks.slack.com/...")
13
+ notifier.notify_blocked(intent="...", reason="PII violation", faults=[...])
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+ from urllib.request import Request, urlopen
21
+
22
+
23
+ class WebhookNotifier:
24
+ """Base notifier for generic webhooks."""
25
+
26
+ def __init__(self, webhook_url: str) -> None:
27
+ self.url = webhook_url
28
+
29
+ def _send(self, payload: dict[str, Any]) -> None:
30
+ data = json.dumps(payload).encode("utf-8")
31
+ req = Request(self.url, data=data, headers={"Content-Type": "application/json"}, method="POST")
32
+ try:
33
+ urlopen(req, timeout=10)
34
+ except Exception:
35
+ pass # Never crash on notification failure
36
+
37
+ def notify(self, decision: str, intent: str, reason: str, faults: list[str], **extra: Any) -> None:
38
+ raise NotImplementedError
39
+
40
+
41
+ class SlackNotifier(WebhookNotifier):
42
+ """Sends CFA governance alerts to Slack."""
43
+
44
+ def notify(self, decision: str, intent: str, reason: str, faults: list[str], **extra: Any) -> None:
45
+ emoji = {"block": "🚫", "replan": "🔄", "rollback": "↩️"}.get(decision, "⚠️")
46
+ color = {"block": "#ef4444", "replan": "#eab308", "rollback": "#ef4444"}.get(decision, "#6b7280")
47
+
48
+ text = f"*{emoji} CFA Governance — {decision.upper()}*\n"
49
+ text += f"*Intent:* {intent[:150]}\n"
50
+ text += f"*Policy:* {extra.get('policy_bundle', 'unknown')}\n"
51
+ text += f"*Reason:* {reason}\n"
52
+ if faults:
53
+ text += f"*Faults:* {', '.join(faults[:5])}\n"
54
+ text += f"\n*Audit:* {extra.get('intent_id', 'n/a')[:8]} | Hash: {extra.get('hash', 'n/a')[:12]}"
55
+
56
+ self._send({
57
+ "attachments": [{"color": color, "text": text, "mrkdwn_in": ["text"]}],
58
+ })
59
+
60
+
61
+ class TeamsNotifier(WebhookNotifier):
62
+ """Sends CFA governance alerts to Microsoft Teams."""
63
+
64
+ def notify(self, decision: str, intent: str, reason: str, faults: list[str], **extra: Any) -> None:
65
+ color = {"block": "FF0000", "replan": "FFA500", "rollback": "FF0000"}.get(decision, "808080")
66
+ sections = [
67
+ {"activityTitle": f"CFA Governance — {decision.upper()}", "facts": [
68
+ {"name": "Intent", "value": intent[:200]},
69
+ {"name": "Policy", "value": extra.get("policy_bundle", "unknown")},
70
+ {"name": "Reason", "value": reason},
71
+ ]},
72
+ ]
73
+ if faults:
74
+ sections[0]["facts"].append({"name": "Faults", "value": ", ".join(faults[:5])})
75
+ self._send({
76
+ "@type": "MessageCard", "@context": "http://schema.org/extensions",
77
+ "themeColor": color, "title": f"CFA: {decision.upper()}",
78
+ "sections": sections,
79
+ })
@@ -0,0 +1,81 @@
1
+ """
2
+ CFA OpenTelemetry Integration
3
+ ==============================
4
+ Optional OTel instrumentation for CFA pipeline phases.
5
+
6
+ Each pipeline phase becomes a span with attributes:
7
+ - cfa.phase, cfa.decision, cfa.signature_hash, cfa.faults, cfa.replan_count
8
+
9
+ Usage:
10
+ from cfa.observability.otel import enable_otel
11
+ enable_otel(service_name="cfa-governance", exporter="console")
12
+ # All subsequent KernelOrchestrator.process() calls are traced
13
+
14
+ Requires: pip install opentelemetry-api opentelemetry-sdk
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from contextlib import contextmanager
20
+ from typing import Any
21
+
22
+
23
+ def _get_tracer():
24
+ try:
25
+ from opentelemetry import trace
26
+ from opentelemetry.sdk.resources import Resource
27
+ from opentelemetry.sdk.trace import TracerProvider
28
+
29
+ provider = trace.get_tracer_provider()
30
+ if not isinstance(provider, TracerProvider):
31
+ resource = Resource.create({"service.name": "cfa-governance"})
32
+ provider = TracerProvider(resource=resource)
33
+ trace.set_tracer_provider(provider)
34
+ return trace.get_tracer("cfa")
35
+ except ImportError:
36
+ return None
37
+
38
+
39
+ def enable_otel(
40
+ service_name: str = "cfa-governance",
41
+ exporter: str = "console",
42
+ otlp_endpoint: str | None = None,
43
+ ) -> bool:
44
+ """Enable OpenTelemetry tracing for CFA.
45
+
46
+ Returns True if OTel was successfully enabled.
47
+ """
48
+ try:
49
+ from opentelemetry import trace
50
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
51
+ from opentelemetry.sdk.resources import Resource
52
+ from opentelemetry.sdk.trace import TracerProvider
53
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
54
+ except ImportError:
55
+ return False
56
+
57
+ resource = Resource.create({"service.name": service_name})
58
+ provider = TracerProvider(resource=resource)
59
+
60
+ if exporter == "otlp" and otlp_endpoint:
61
+ otlp = OTLPSpanExporter(endpoint=otlp_endpoint)
62
+ provider.add_span_processor(BatchSpanProcessor(otlp))
63
+ else:
64
+ provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
65
+
66
+ trace.set_tracer_provider(provider)
67
+ return True
68
+
69
+
70
+ @contextmanager
71
+ def cfa_span(name: str, **attrs: Any):
72
+ """Context manager for a CFA span. Falls back to no-op if OTel unavailable."""
73
+ tracer = _get_tracer()
74
+ if tracer is None:
75
+ yield
76
+ return
77
+ with tracer.start_as_current_span(name, attributes=attrs) as span:
78
+ span.set_attribute("cfa.phase", attrs.get("phase", name))
79
+ for k, v in attrs.items():
80
+ span.set_attribute(f"cfa.{k}", str(v))
81
+ yield
@@ -0,0 +1,367 @@
1
+ """
2
+ CFA Promotion / Demotion Engine
3
+ ================================
4
+ Intent lifecycle management through evidence-based promotion and demotion.
5
+
6
+ Lifecycle states:
7
+ candidate → active → watchlist → deprecated → retired
8
+ ↑ ↓
9
+ └─── re-promotion ─────┘
10
+
11
+ Promotion requires accumulated evidence over a time window:
12
+ Promote ⟸ IFo ≥ T1 AND IFs ≥ T2 AND IFg = 1 AND executions ≥ min_executions
13
+
14
+ Demotion triggers:
15
+ - Schema drift → watchlist → deprecated
16
+ - Policy change → demoted
17
+ - IFs degraded → watchlist
18
+ - IFo consistently low → watchlist
19
+ - Low reuse → watchlist → deprecated
20
+ - Catalog incompatibility → retired
21
+ - IDI < 0.50 → immediate demotion (severe drift)
22
+
23
+ Every promoted skill carries generation_metadata for traceability.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime
30
+ from enum import StrEnum
31
+ from typing import Any
32
+
33
+ from cfa.observability.indices import ExecutionRecord, IndexCalculator, IndexScores
34
+ from cfa.types import _utcnow
35
+
36
+ # ── Lifecycle States ────────────────────────────────────────────────────────
37
+
38
+
39
+ class SkillState(StrEnum):
40
+ CANDIDATE = "candidate"
41
+ ACTIVE = "active"
42
+ WATCHLIST = "watchlist"
43
+ DEPRECATED = "deprecated"
44
+ RETIRED = "retired"
45
+ DEMOTED = "demoted"
46
+
47
+
48
+ # ── Promotion Policy ────────────────────────────────────────────────────────
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PromotionPolicy:
53
+ """Configurable thresholds for promotion gate."""
54
+
55
+ min_executions: int = 3
56
+ evaluation_window_days: int = 7
57
+ ifo_threshold: float = 0.75
58
+ ifs_threshold: float = 0.90
59
+ ifg_threshold: float = 1.0 # binary — no exception
60
+ idi_watchlist_threshold: float = 0.75
61
+ idi_demotion_threshold: float = 0.50
62
+ inactivity_periods_for_deprecation: int = 3 # consecutive windows without execution
63
+
64
+
65
+ # ── Skill Record ────────────────────────────────────────────────────────────
66
+
67
+
68
+ @dataclass
69
+ class SkillGenerationMetadata:
70
+ """Provenance metadata for promoted skills (traceability)."""
71
+
72
+ promoted_at: datetime = field(default_factory=_utcnow)
73
+ promoted_by_system_version: str = "cfa_v0.1.0"
74
+ policy_bundle_at_promotion: str = ""
75
+ catalog_snapshot_at_promotion: str = ""
76
+ promotion_scores: dict[str, float] = field(default_factory=dict)
77
+ execution_count_at_promotion: int = 0
78
+ evaluation_window: str = ""
79
+
80
+
81
+ @dataclass
82
+ class SkillRecord:
83
+ """Tracked lifecycle record for a signature_hash."""
84
+
85
+ signature_hash: str
86
+ state: SkillState = SkillState.CANDIDATE
87
+ generation_metadata: SkillGenerationMetadata | None = None
88
+ last_evaluation: datetime | None = None
89
+ demotion_reason: str = ""
90
+ consecutive_inactive_windows: int = 0
91
+ history: list[dict[str, Any]] = field(default_factory=list)
92
+
93
+ def transition(self, new_state: SkillState, reason: str = "") -> None:
94
+ self.history.append({
95
+ "from": self.state.value,
96
+ "to": new_state.value,
97
+ "reason": reason,
98
+ "timestamp": _utcnow().isoformat(),
99
+ })
100
+ self.state = new_state
101
+ if new_state == SkillState.DEMOTED:
102
+ self.demotion_reason = reason
103
+
104
+
105
+ # ── Promotion / Demotion Engine ─────────────────────────────────────────────
106
+
107
+
108
+ class PromotionEngine:
109
+ """
110
+ Evaluates intent_signature_hash lifecycle.
111
+
112
+ Called after execution to:
113
+ 1. Compute indices (IFo, IFs, IFg, IDI)
114
+ 2. Evaluate promotion gate
115
+ 3. Check demotion triggers
116
+ 4. Update skill state
117
+
118
+ When ``storage`` is provided, execution records and skill state are
119
+ persisted to SQLite (or any object with compatible methods).
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ policy: PromotionPolicy | None = None,
125
+ system_version: str = "cfa_v0.1.0",
126
+ storage: object | None = None,
127
+ ) -> None:
128
+ self.policy = policy or PromotionPolicy()
129
+ self.system_version = system_version
130
+ self._storage = storage
131
+ self._skills: dict[str, SkillRecord] = {}
132
+ self._records: list[ExecutionRecord] = []
133
+ self._calculator = IndexCalculator(window_days=self.policy.evaluation_window_days)
134
+ if self._storage is not None:
135
+ self._load_from_storage()
136
+
137
+ def _load_from_storage(self) -> None:
138
+ if not hasattr(self._storage, "execution_load_all"):
139
+ return
140
+ for rec_dict in self._storage.execution_load_all():
141
+ self._records.append(ExecutionRecord(
142
+ signature_hash=rec_dict["signature_hash"],
143
+ timestamp=datetime.fromisoformat(rec_dict["timestamp"]) if rec_dict.get("timestamp") else _utcnow(),
144
+ success=rec_dict.get("success", True),
145
+ replanned=rec_dict.get("replanned", False),
146
+ cost_dbu=rec_dict.get("cost_dbu", 0.0),
147
+ duration_seconds=rec_dict.get("duration_seconds", 0.0),
148
+ faults=rec_dict.get("faults", []),
149
+ schema_match=rec_dict.get("schema_match", True),
150
+ pii_exposure=rec_dict.get("pii_exposure", False),
151
+ policy_compliant=rec_dict.get("policy_compliant", True),
152
+ layer_adherent=rec_dict.get("layer_adherent", True),
153
+ max_expected_duration=rec_dict.get("max_expected_duration", 300.0),
154
+ max_expected_cost=rec_dict.get("max_expected_cost", 50.0),
155
+ ))
156
+ if hasattr(self._storage, "skill_load_all"):
157
+ for skill_dict in self._storage.skill_load_all():
158
+ sig_hash = skill_dict["signature_hash"]
159
+ skill = SkillRecord(signature_hash=sig_hash)
160
+ skill.state = SkillState(skill_dict.get("state", "candidate"))
161
+ if skill_dict.get("generation_metadata"):
162
+ gm = skill_dict["generation_metadata"]
163
+ skill.generation_metadata = SkillGenerationMetadata(
164
+ promoted_by_system_version=gm.get("promoted_by_system_version", ""),
165
+ policy_bundle_at_promotion=gm.get("policy_bundle_at_promotion", ""),
166
+ catalog_snapshot_at_promotion=gm.get("catalog_snapshot_at_promotion", ""),
167
+ promotion_scores=gm.get("promotion_scores", {}),
168
+ execution_count_at_promotion=gm.get("execution_count_at_promotion", 0),
169
+ evaluation_window=gm.get("evaluation_window", ""),
170
+ )
171
+ skill.demotion_reason = skill_dict.get("demotion_reason", "")
172
+ skill.consecutive_inactive_windows = skill_dict.get("consecutive_inactive_windows", 0)
173
+ skill.history = skill_dict.get("history", [])
174
+ self._skills[sig_hash] = skill
175
+
176
+ def get_skill(self, signature_hash: str) -> SkillRecord:
177
+ if signature_hash not in self._skills:
178
+ self._skills[signature_hash] = SkillRecord(signature_hash=signature_hash)
179
+ return self._skills[signature_hash]
180
+
181
+ def record_execution(self, record: ExecutionRecord) -> None:
182
+ """Record an execution for index computation."""
183
+ self._records.append(record)
184
+ if self._storage is not None and hasattr(self._storage, "execution_append"):
185
+ self._storage.execution_append({
186
+ "signature_hash": record.signature_hash,
187
+ "timestamp": record.timestamp.isoformat(),
188
+ "success": record.success,
189
+ "replanned": record.replanned,
190
+ "cost_dbu": record.cost_dbu,
191
+ "duration_seconds": record.duration_seconds,
192
+ "faults": record.faults,
193
+ "schema_match": record.schema_match,
194
+ "pii_exposure": record.pii_exposure,
195
+ "policy_compliant": record.policy_compliant,
196
+ "layer_adherent": record.layer_adherent,
197
+ "max_expected_duration": record.max_expected_duration,
198
+ "max_expected_cost": record.max_expected_cost,
199
+ })
200
+
201
+ def _persist_skill(self, signature_hash: str) -> None:
202
+ if self._storage is None or not hasattr(self._storage, "skill_upsert"):
203
+ return
204
+ skill = self._skills.get(signature_hash)
205
+ if skill is None:
206
+ return
207
+ gm = {}
208
+ if skill.generation_metadata is not None:
209
+ gm = {
210
+ "promoted_by_system_version": skill.generation_metadata.promoted_by_system_version,
211
+ "policy_bundle_at_promotion": skill.generation_metadata.policy_bundle_at_promotion,
212
+ "catalog_snapshot_at_promotion": skill.generation_metadata.catalog_snapshot_at_promotion,
213
+ "promotion_scores": skill.generation_metadata.promotion_scores,
214
+ "execution_count_at_promotion": skill.generation_metadata.execution_count_at_promotion,
215
+ "evaluation_window": skill.generation_metadata.evaluation_window,
216
+ }
217
+ self._storage.skill_upsert(signature_hash, {
218
+ "state": skill.state.value,
219
+ "generation_metadata": gm,
220
+ "last_evaluation": skill.last_evaluation.isoformat() if skill.last_evaluation else "",
221
+ "demotion_reason": skill.demotion_reason,
222
+ "consecutive_inactive_windows": skill.consecutive_inactive_windows,
223
+ "history": skill.history,
224
+ })
225
+
226
+ def evaluate(
227
+ self,
228
+ signature_hash: str,
229
+ policy_bundle_version: str = "",
230
+ catalog_snapshot_version: str = "",
231
+ ) -> tuple[SkillRecord, IndexScores]:
232
+ """
233
+ Evaluate a signature_hash for promotion or demotion.
234
+ Returns updated SkillRecord and computed IndexScores.
235
+ """
236
+ skill = self.get_skill(signature_hash)
237
+ scores = self._calculator.compute(signature_hash, self._records)
238
+ skill.last_evaluation = _utcnow()
239
+
240
+ # ── Check demotion triggers first (demotion takes precedence) ───
241
+ demoted = self._check_demotion(skill, scores)
242
+ if demoted:
243
+ self._persist_skill(signature_hash)
244
+ return skill, scores
245
+
246
+ # ── Check promotion gate ────────────────────────────────────────
247
+ promoted = False
248
+ if skill.state in (SkillState.CANDIDATE, SkillState.WATCHLIST, SkillState.DEMOTED):
249
+ promoted = self._check_promotion(
250
+ skill, scores, policy_bundle_version, catalog_snapshot_version
251
+ )
252
+
253
+ # ── Check inactivity ────────────────────────────────────────────
254
+ if scores.execution_count == 0 and skill.state in (SkillState.ACTIVE, SkillState.WATCHLIST):
255
+ skill.consecutive_inactive_windows += 1
256
+ if skill.consecutive_inactive_windows >= self.policy.inactivity_periods_for_deprecation:
257
+ skill.transition(SkillState.DEPRECATED, "Low reuse — no executions for multiple windows")
258
+ else:
259
+ skill.consecutive_inactive_windows = 0
260
+
261
+ if demoted or promoted or skill.consecutive_inactive_windows > 0:
262
+ self._persist_skill(signature_hash)
263
+ return skill, scores
264
+
265
+ def _check_promotion(
266
+ self,
267
+ skill: SkillRecord,
268
+ scores: IndexScores,
269
+ policy_bundle_version: str,
270
+ catalog_snapshot_version: str,
271
+ ) -> bool:
272
+ """Check if skill meets promotion gate. Returns True if promoted."""
273
+ if scores.execution_count < self.policy.min_executions:
274
+ return False
275
+
276
+ gate = (
277
+ scores.ifo >= self.policy.ifo_threshold
278
+ and scores.ifs >= self.policy.ifs_threshold
279
+ and scores.ifg >= self.policy.ifg_threshold
280
+ )
281
+
282
+ if gate:
283
+ skill.generation_metadata = SkillGenerationMetadata(
284
+ promoted_by_system_version=self.system_version,
285
+ policy_bundle_at_promotion=policy_bundle_version,
286
+ catalog_snapshot_at_promotion=catalog_snapshot_version,
287
+ promotion_scores={"IFo": scores.ifo, "IFs": scores.ifs, "IFg": scores.ifg},
288
+ execution_count_at_promotion=scores.execution_count,
289
+ evaluation_window=f"last_{self.policy.evaluation_window_days}_days",
290
+ )
291
+ skill.transition(SkillState.ACTIVE, "Promotion gate passed")
292
+ return True
293
+ return False
294
+
295
+ def _check_demotion(self, skill: SkillRecord, scores: IndexScores) -> bool:
296
+ """Check demotion triggers. Returns True if demoted/watchlisted."""
297
+ if skill.state not in (SkillState.ACTIVE, SkillState.WATCHLIST):
298
+ return False
299
+
300
+ # IDI < 0.50 → immediate demotion (severe drift)
301
+ if scores.severe_drift:
302
+ skill.transition(SkillState.DEMOTED, f"Severe drift: IDI={scores.idi:.2f}")
303
+ return True
304
+
305
+ # IFg < 1 → systemic failure, immediate demotion
306
+ if scores.ifg < 1.0 and scores.execution_count > 0:
307
+ skill.transition(SkillState.DEMOTED, f"Governance violation: IFg={scores.ifg}")
308
+ return True
309
+
310
+ # IDI < 0.75 → watchlist
311
+ if scores.drift_detected and skill.state == SkillState.ACTIVE:
312
+ skill.transition(SkillState.WATCHLIST, f"Drift detected: IDI={scores.idi:.2f}")
313
+ return True
314
+
315
+ # IFs below threshold → watchlist
316
+ if scores.ifs < self.policy.ifs_threshold and skill.state == SkillState.ACTIVE:
317
+ skill.transition(SkillState.WATCHLIST, f"IFs degraded: {scores.ifs:.2f}")
318
+ return True
319
+
320
+ # IFo below threshold → watchlist
321
+ if scores.ifo < self.policy.ifo_threshold and skill.state == SkillState.ACTIVE:
322
+ skill.transition(SkillState.WATCHLIST, f"IFo low: {scores.ifo:.2f}")
323
+ return True
324
+
325
+ # Watchlist + still below thresholds → deprecated
326
+ if skill.state == SkillState.WATCHLIST:
327
+ still_bad = (
328
+ scores.ifs < self.policy.ifs_threshold
329
+ or scores.ifo < self.policy.ifo_threshold
330
+ )
331
+ if still_bad and scores.execution_count >= self.policy.min_executions:
332
+ skill.transition(SkillState.DEPRECATED, "Sustained degradation in watchlist")
333
+ return True
334
+
335
+ return False
336
+
337
+ def retire_for_catalog_change(self, signature_hash: str, reason: str = "") -> SkillRecord:
338
+ """Force-retire a skill due to catalog incompatibility."""
339
+ skill = self.get_skill(signature_hash)
340
+ skill.transition(
341
+ SkillState.RETIRED,
342
+ reason or "Catalog incompatibility — dataset or domain removed",
343
+ )
344
+ self._persist_skill(signature_hash)
345
+ return skill
346
+
347
+ def demote_by_system_version(self, system_version: str, reason: str = "") -> list[SkillRecord]:
348
+ """Mass-demote all skills promoted by a specific system version."""
349
+ demoted = []
350
+ for skill in self._skills.values():
351
+ if (
352
+ skill.generation_metadata is not None
353
+ and skill.generation_metadata.promoted_by_system_version == system_version
354
+ and skill.state == SkillState.ACTIVE
355
+ ):
356
+ skill.transition(
357
+ SkillState.DEMOTED,
358
+ reason or f"Mass demotion: system version {system_version} flagged",
359
+ )
360
+ self._persist_skill(skill.signature_hash)
361
+ demoted.append(skill)
362
+ return demoted
363
+
364
+ def list_skills(self, state: SkillState | None = None) -> list[SkillRecord]:
365
+ if state is None:
366
+ return list(self._skills.values())
367
+ return [s for s in self._skills.values() if s.state == state]
cfa/policy/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """CFA Policy — policy engine, bundles, and catalog."""
2
+ from cfa._lazy import LazyLoader
3
+
4
+ __getattr__ = LazyLoader({
5
+ "PolicyEngine": ("cfa.policy.engine", "PolicyEngine"),
6
+ "PolicyRule": ("cfa.policy.engine", "PolicyRule"),
7
+ "build_default_ruleset": ("cfa.policy.engine", "build_default_ruleset"),
8
+ "PolicyBundle": ("cfa.policy.bundle", "PolicyBundle"),
9
+ "validate_policy_bundle_data": ("cfa.policy.bundle", "validate_policy_bundle_data"),
10
+ "list_available_bundles": ("cfa.policy.bundle", "list_available_bundles"),
11
+ "validate_catalog": ("cfa.policy.catalog", "validate_catalog"),
12
+ })