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.
- cfa/__init__.py +39 -0
- cfa/_lazy.py +39 -0
- cfa/adapters/__init__.py +104 -0
- cfa/adapters/autogen.py +19 -0
- cfa/adapters/crewai.py +19 -0
- cfa/adapters/dspy.py +19 -0
- cfa/adapters/langgraph.py +19 -0
- cfa/adapters/openai_agents.py +19 -0
- cfa/audit/__init__.py +15 -0
- cfa/audit/context.py +205 -0
- cfa/audit/hashing.py +41 -0
- cfa/audit/trail.py +194 -0
- cfa/backends/__init__.py +132 -0
- cfa/backends/dbt.py +338 -0
- cfa/backends/pyspark.py +240 -0
- cfa/backends/sql.py +270 -0
- cfa/behavior/__init__.py +49 -0
- cfa/behavior/llm.py +244 -0
- cfa/behavior/spec.py +235 -0
- cfa/behavior/systematizer.py +222 -0
- cfa/cli/__init__.py +296 -0
- cfa/cli/__main__.py +6 -0
- cfa/cli/_helpers.py +109 -0
- cfa/cli/core/__init__.py +0 -0
- cfa/cli/core/evaluate.py +72 -0
- cfa/cli/core/validate.py +29 -0
- cfa/cli/formatters.py +280 -0
- cfa/cli/governance/__init__.py +0 -0
- cfa/cli/governance/audit.py +65 -0
- cfa/cli/governance/catalog.py +28 -0
- cfa/cli/governance/policy.py +119 -0
- cfa/cli/governance/rules.py +42 -0
- cfa/cli/governance/signature.py +31 -0
- cfa/cli/infrastructure/__init__.py +0 -0
- cfa/cli/infrastructure/backend_list.py +24 -0
- cfa/cli/infrastructure/storage.py +87 -0
- cfa/cli/project/__init__.py +0 -0
- cfa/cli/project/init.py +73 -0
- cfa/cli/project/lifecycle.py +92 -0
- cfa/cli/project/status.py +75 -0
- cfa/cli/project/taxonomy.py +38 -0
- cfa/cli/reporting/__init__.py +0 -0
- cfa/cli/reporting/report.py +109 -0
- cfa/cli/reporting/serve.py +43 -0
- cfa/config.py +103 -0
- cfa/core/__init__.py +19 -0
- cfa/core/codegen.py +65 -0
- cfa/core/conditions.py +129 -0
- cfa/core/kernel.py +224 -0
- cfa/core/phases/__init__.py +0 -0
- cfa/core/phases/runner.py +477 -0
- cfa/core/planner.py +290 -0
- cfa/execution/__init__.py +12 -0
- cfa/execution/partial.py +339 -0
- cfa/execution/state_projection.py +216 -0
- cfa/governance/__init__.py +76 -0
- cfa/lifecycle/__init__.py +51 -0
- cfa/mcp/__init__.py +347 -0
- cfa/mcp/__main__.py +4 -0
- cfa/normalizer/__init__.py +15 -0
- cfa/normalizer/base.py +441 -0
- cfa/normalizer/llm.py +426 -0
- cfa/observability/__init__.py +14 -0
- cfa/observability/indices.py +177 -0
- cfa/observability/metrics.py +91 -0
- cfa/observability/notify.py +79 -0
- cfa/observability/otel.py +81 -0
- cfa/observability/promotion.py +367 -0
- cfa/policy/__init__.py +12 -0
- cfa/policy/bundle.py +317 -0
- cfa/policy/catalog.py +117 -0
- cfa/policy/engine.py +306 -0
- cfa/reporting/__init__.py +42 -0
- cfa/reporting/charts.py +223 -0
- cfa/reporting/engine.py +456 -0
- cfa/resolution/__init__.py +62 -0
- cfa/runtime/__init__.py +13 -0
- cfa/runtime/gate.py +287 -0
- cfa/sandbox/__init__.py +189 -0
- cfa/sandbox/executor.py +92 -0
- cfa/sandbox/mock.py +89 -0
- cfa/sandbox/panic.py +52 -0
- cfa/storage/__init__.py +591 -0
- cfa/testing/__init__.py +60 -0
- cfa/testing/asserts.py +77 -0
- cfa/testing/evaluate.py +168 -0
- cfa/testing/fixtures.py +89 -0
- cfa/testing/markers.py +36 -0
- cfa/types.py +489 -0
- cfa/validation/__init__.py +14 -0
- cfa/validation/runtime.py +285 -0
- cfa/validation/signature.py +146 -0
- cfa/validation/static.py +252 -0
- cfa_kernel-0.1.0.dist-info/METADATA +32 -0
- cfa_kernel-0.1.0.dist-info/RECORD +98 -0
- cfa_kernel-0.1.0.dist-info/WHEEL +4 -0
- cfa_kernel-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
})
|