crprotocol 2.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.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
crp/ckf/merge.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""CKF multi-mode merge — deduplicate, score, rank, community boost (§3.8).
|
|
4
|
+
|
|
5
|
+
``multi_mode_merge()`` combines results from all four CKF retrieval modes
|
|
6
|
+
into a single ranked list, applying deduplication and community-coherence boosting.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from crp.extraction.types import Fact
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Config
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
# Weight per mode (graph_walk, pattern, semantic, community)
|
|
20
|
+
DEFAULT_MODE_WEIGHTS: dict[str, float] = {
|
|
21
|
+
"graph_walk": 0.30,
|
|
22
|
+
"pattern": 0.25,
|
|
23
|
+
"semantic": 0.30,
|
|
24
|
+
"community": 0.15,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Community coherence boost: facts from same community as top fact get +boost
|
|
28
|
+
COMMUNITY_BOOST = 0.10
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Result type
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MergedFact:
|
|
38
|
+
"""A fact with a merged score from multiple retrieval modes."""
|
|
39
|
+
|
|
40
|
+
fact: Fact
|
|
41
|
+
score: float = 0.0
|
|
42
|
+
sources: list[str] = field(default_factory=list)
|
|
43
|
+
community_boosted: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class MergeResult:
|
|
48
|
+
"""Result of multi-mode merge."""
|
|
49
|
+
|
|
50
|
+
facts: list[MergedFact] = field(default_factory=list)
|
|
51
|
+
total_candidates: int = 0
|
|
52
|
+
deduplicated: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Public API
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def multi_mode_merge(
|
|
61
|
+
mode_results: dict[str, list[tuple[Fact, float]]],
|
|
62
|
+
fact_to_community: dict[str, int] | None = None,
|
|
63
|
+
mode_weights: dict[str, float] | None = None,
|
|
64
|
+
max_results: int = 200,
|
|
65
|
+
) -> MergeResult:
|
|
66
|
+
"""Merge results from multiple CKF retrieval modes.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
mode_results : dict[str, list[tuple[Fact, float]]]
|
|
71
|
+
Mapping of mode name → list of (fact, score) tuples.
|
|
72
|
+
Mode names: "graph_walk", "pattern", "semantic", "community".
|
|
73
|
+
fact_to_community : dict[str, int] | None
|
|
74
|
+
Mapping of fact_id → community_id for community boosting.
|
|
75
|
+
mode_weights : dict[str, float] | None
|
|
76
|
+
Override default mode weights.
|
|
77
|
+
max_results : int
|
|
78
|
+
Maximum number of facts to return.
|
|
79
|
+
"""
|
|
80
|
+
weights = mode_weights or DEFAULT_MODE_WEIGHTS
|
|
81
|
+
|
|
82
|
+
# --- Phase 1: Aggregate scores ---
|
|
83
|
+
scores: dict[str, float] = {}
|
|
84
|
+
sources: dict[str, list[str]] = {}
|
|
85
|
+
fact_by_id: dict[str, Fact] = {}
|
|
86
|
+
total_candidates = 0
|
|
87
|
+
|
|
88
|
+
for mode_name, results in mode_results.items():
|
|
89
|
+
weight = weights.get(mode_name, 0.2)
|
|
90
|
+
for fact, raw_score in results:
|
|
91
|
+
total_candidates += 1
|
|
92
|
+
fid = fact.id
|
|
93
|
+
fact_by_id[fid] = fact
|
|
94
|
+
scores[fid] = scores.get(fid, 0.0) + raw_score * weight
|
|
95
|
+
sources.setdefault(fid, []).append(mode_name)
|
|
96
|
+
|
|
97
|
+
# --- Phase 2: Community boost ---
|
|
98
|
+
if fact_to_community and scores:
|
|
99
|
+
# Find the top fact's community
|
|
100
|
+
top_fid = max(scores, key=lambda k: scores[k])
|
|
101
|
+
top_comm = fact_to_community.get(top_fid)
|
|
102
|
+
if top_comm is not None:
|
|
103
|
+
for fid in scores:
|
|
104
|
+
if fact_to_community.get(fid) == top_comm and fid != top_fid:
|
|
105
|
+
scores[fid] += COMMUNITY_BOOST
|
|
106
|
+
|
|
107
|
+
# --- Phase 3: Rank and build result ---
|
|
108
|
+
ranked = sorted(scores.items(), key=lambda x: -x[1])
|
|
109
|
+
merged_facts: list[MergedFact] = []
|
|
110
|
+
for fid, score in ranked[:max_results]:
|
|
111
|
+
fact = fact_by_id.get(fid) # type: ignore[assignment]
|
|
112
|
+
if fact is None:
|
|
113
|
+
continue
|
|
114
|
+
boosted = False
|
|
115
|
+
if fact_to_community:
|
|
116
|
+
top_fid = ranked[0][0] if ranked else ""
|
|
117
|
+
top_comm = fact_to_community.get(top_fid)
|
|
118
|
+
if top_comm is not None and fact_to_community.get(fid) == top_comm and fid != top_fid:
|
|
119
|
+
boosted = True
|
|
120
|
+
merged_facts.append(
|
|
121
|
+
MergedFact(
|
|
122
|
+
fact=fact,
|
|
123
|
+
score=score,
|
|
124
|
+
sources=sources.get(fid, []),
|
|
125
|
+
community_boosted=boosted,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return MergeResult(
|
|
130
|
+
facts=merged_facts,
|
|
131
|
+
total_candidates=total_candidates,
|
|
132
|
+
deduplicated=total_candidates - len(merged_facts),
|
|
133
|
+
)
|
crp/ckf/pattern_query.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""CKF Mode 2: Pattern query — structured matching by entity/relation type (§3.8).
|
|
4
|
+
|
|
5
|
+
``pattern_query(graph, entity_type, relationship_type)`` filters facts
|
|
6
|
+
and edges by category and relation type.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from crp.extraction.types import Fact, FactEdge, FactGraph, RelationType
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Result type
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class PatternQueryResult:
|
|
23
|
+
"""Result of a pattern query."""
|
|
24
|
+
|
|
25
|
+
facts: list[Fact] = field(default_factory=list)
|
|
26
|
+
edges: list[FactEdge] = field(default_factory=list)
|
|
27
|
+
match_count: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Pattern query
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pattern_query(
|
|
36
|
+
graph: FactGraph,
|
|
37
|
+
entity_type: str | None = None,
|
|
38
|
+
relationship_type: str | RelationType | None = None,
|
|
39
|
+
min_confidence: float = 0.0,
|
|
40
|
+
max_results: int = 200,
|
|
41
|
+
metadata_filter: dict[str, Any] | None = None,
|
|
42
|
+
) -> PatternQueryResult:
|
|
43
|
+
"""Structured query: filter facts by category and edges by relation type.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
graph : FactGraph
|
|
48
|
+
entity_type : str | None
|
|
49
|
+
Filter facts whose ``category`` matches (case-insensitive).
|
|
50
|
+
relationship_type : str | RelationType | None
|
|
51
|
+
Filter edges whose ``relation_type`` matches.
|
|
52
|
+
min_confidence : float
|
|
53
|
+
Minimum confidence threshold for both facts and edges.
|
|
54
|
+
max_results : int
|
|
55
|
+
Cap on returned facts.
|
|
56
|
+
metadata_filter : dict | None
|
|
57
|
+
Key-value pairs that must all appear in ``fact.metadata``.
|
|
58
|
+
"""
|
|
59
|
+
# Normalise relationship type for comparison
|
|
60
|
+
rel_str: str | None = None
|
|
61
|
+
if relationship_type is not None:
|
|
62
|
+
rel_str = (
|
|
63
|
+
relationship_type.value
|
|
64
|
+
if isinstance(relationship_type, RelationType)
|
|
65
|
+
else str(relationship_type)
|
|
66
|
+
).upper()
|
|
67
|
+
|
|
68
|
+
ent_upper = entity_type.upper() if entity_type else None
|
|
69
|
+
|
|
70
|
+
# --- Filter edges first (to collect connected fact IDs) ---
|
|
71
|
+
matched_edges: list[FactEdge] = []
|
|
72
|
+
edge_fact_ids: set[str] = set()
|
|
73
|
+
|
|
74
|
+
if rel_str is not None:
|
|
75
|
+
for edge in graph.edges:
|
|
76
|
+
edge_rel = edge.relation_type
|
|
77
|
+
if isinstance(edge_rel, RelationType):
|
|
78
|
+
edge_rel = edge_rel.value
|
|
79
|
+
if str(edge_rel).upper() == rel_str and edge.confidence >= min_confidence:
|
|
80
|
+
matched_edges.append(edge)
|
|
81
|
+
edge_fact_ids.add(edge.source_id)
|
|
82
|
+
edge_fact_ids.add(edge.target_id)
|
|
83
|
+
|
|
84
|
+
# --- Filter facts ---
|
|
85
|
+
matched_facts: list[Fact] = []
|
|
86
|
+
for fact in graph.nodes.values():
|
|
87
|
+
# Confidence gate
|
|
88
|
+
if fact.confidence < min_confidence:
|
|
89
|
+
continue
|
|
90
|
+
# Skip superseded
|
|
91
|
+
if fact.superseded_by is not None:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
include = True
|
|
95
|
+
|
|
96
|
+
# Category filter
|
|
97
|
+
if ent_upper is not None and fact.category.upper() != ent_upper:
|
|
98
|
+
include = False
|
|
99
|
+
|
|
100
|
+
# Metadata filter
|
|
101
|
+
if metadata_filter:
|
|
102
|
+
for k, v in metadata_filter.items():
|
|
103
|
+
if fact.metadata.get(k) != v:
|
|
104
|
+
include = False
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
# If relationship filter given, fact must be connected by matching edge
|
|
108
|
+
if rel_str is not None and fact.id not in edge_fact_ids:
|
|
109
|
+
include = False
|
|
110
|
+
|
|
111
|
+
if include:
|
|
112
|
+
matched_facts.append(fact)
|
|
113
|
+
|
|
114
|
+
# Sort by confidence desc
|
|
115
|
+
matched_facts.sort(key=lambda f: -(f.confidence or 0.0))
|
|
116
|
+
matched_facts = matched_facts[:max_results]
|
|
117
|
+
|
|
118
|
+
return PatternQueryResult(
|
|
119
|
+
facts=matched_facts,
|
|
120
|
+
edges=matched_edges,
|
|
121
|
+
match_count=len(matched_facts),
|
|
122
|
+
)
|
crp/ckf/pubsub.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""PubSub event bus for CKF internal notifications (§3.8).
|
|
4
|
+
|
|
5
|
+
Events: fact_created, fact_superseded, edge_added, community_updated, anomaly_detected.
|
|
6
|
+
Subscribers receive typed payloads asynchronously (fire-and-forget).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Event types
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CKFEventType(str, Enum):
|
|
26
|
+
"""Events emitted by the CKF subsystem."""
|
|
27
|
+
|
|
28
|
+
FACT_CREATED = "fact_created"
|
|
29
|
+
FACT_SUPERSEDED = "fact_superseded"
|
|
30
|
+
EDGE_ADDED = "edge_added"
|
|
31
|
+
COMMUNITY_UPDATED = "community_updated"
|
|
32
|
+
ANOMALY_DETECTED = "anomaly_detected"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CKFEvent:
|
|
37
|
+
"""Payload for a CKF event."""
|
|
38
|
+
|
|
39
|
+
event_type: CKFEventType
|
|
40
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
source: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Callback type
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
EventCallback = Callable[[CKFEvent], None]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# PubSubEventBus
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PubSubEventBus:
|
|
57
|
+
"""Thread-safe pub/sub for CKF lifecycle events.
|
|
58
|
+
|
|
59
|
+
Usage::
|
|
60
|
+
|
|
61
|
+
bus = PubSubEventBus()
|
|
62
|
+
bus.subscribe(CKFEventType.FACT_CREATED, my_handler)
|
|
63
|
+
bus.publish(CKFEvent(CKFEventType.FACT_CREATED, {"fact_id": "abc"}))
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
self._subscribers: dict[CKFEventType, list[EventCallback]] = {
|
|
69
|
+
et: [] for et in CKFEventType
|
|
70
|
+
}
|
|
71
|
+
self._global: list[EventCallback] = []
|
|
72
|
+
|
|
73
|
+
# --- Subscribe ---
|
|
74
|
+
|
|
75
|
+
def subscribe(
|
|
76
|
+
self,
|
|
77
|
+
event_type: CKFEventType,
|
|
78
|
+
callback: EventCallback,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Register *callback* for events of *event_type*."""
|
|
81
|
+
with self._lock:
|
|
82
|
+
self._subscribers[event_type].append(callback)
|
|
83
|
+
|
|
84
|
+
def subscribe_all(self, callback: EventCallback) -> None:
|
|
85
|
+
"""Register *callback* for all event types."""
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._global.append(callback)
|
|
88
|
+
|
|
89
|
+
def unsubscribe(
|
|
90
|
+
self,
|
|
91
|
+
event_type: CKFEventType,
|
|
92
|
+
callback: EventCallback,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Remove *callback* from *event_type* subscribers."""
|
|
95
|
+
with self._lock:
|
|
96
|
+
subs = self._subscribers.get(event_type, [])
|
|
97
|
+
if callback in subs:
|
|
98
|
+
subs.remove(callback)
|
|
99
|
+
|
|
100
|
+
# --- Publish ---
|
|
101
|
+
|
|
102
|
+
def publish(self, event: CKFEvent) -> None:
|
|
103
|
+
"""Dispatch *event* to all subscribers. Fire-and-forget — errors logged."""
|
|
104
|
+
with self._lock:
|
|
105
|
+
callbacks = list(self._subscribers.get(event.event_type, []))
|
|
106
|
+
callbacks += list(self._global)
|
|
107
|
+
|
|
108
|
+
for cb in callbacks:
|
|
109
|
+
try:
|
|
110
|
+
cb(event)
|
|
111
|
+
except Exception: # noqa: BLE001
|
|
112
|
+
logger.exception("CKF event handler error for %s", event.event_type)
|
|
113
|
+
|
|
114
|
+
# --- Introspection ---
|
|
115
|
+
|
|
116
|
+
def subscriber_count(self, event_type: CKFEventType | None = None) -> int:
|
|
117
|
+
"""Return number of subscribers, optionally filtered by event type."""
|
|
118
|
+
with self._lock:
|
|
119
|
+
if event_type is None:
|
|
120
|
+
return sum(len(v) for v in self._subscribers.values()) + len(self._global)
|
|
121
|
+
return len(self._subscribers.get(event_type, [])) + len(self._global)
|
|
122
|
+
|
|
123
|
+
def clear(self) -> None:
|
|
124
|
+
"""Remove all subscribers."""
|
|
125
|
+
with self._lock:
|
|
126
|
+
for et in self._subscribers:
|
|
127
|
+
self._subscribers[et] = []
|
|
128
|
+
self._global = []
|
crp/ckf/semantic.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""CKF Mode 3: Semantic fallback — ANN similarity retrieval (§3.8).
|
|
4
|
+
|
|
5
|
+
``semantic_fallback(query, facts, top_k)`` retrieves the *top_k* most
|
|
6
|
+
semantically similar facts to *query*. Uses HNSW when hnswlib is available,
|
|
7
|
+
otherwise brute-force cosine similarity.
|
|
8
|
+
|
|
9
|
+
Adaptive top_k: 20 (default), scales up to 200 for large stores.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from crp.state.fact import StateFact
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# ANN backend detection
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_HNSWLIB: Any = None
|
|
28
|
+
_HNSWLIB_CHECKED = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_hnswlib() -> Any:
|
|
32
|
+
global _HNSWLIB, _HNSWLIB_CHECKED # noqa: PLW0603
|
|
33
|
+
if not _HNSWLIB_CHECKED:
|
|
34
|
+
try:
|
|
35
|
+
import hnswlib # type: ignore[import-untyped]
|
|
36
|
+
|
|
37
|
+
_HNSWLIB = hnswlib
|
|
38
|
+
except ImportError:
|
|
39
|
+
_HNSWLIB = None
|
|
40
|
+
_HNSWLIB_CHECKED = True
|
|
41
|
+
return _HNSWLIB
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Result type
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SemanticResult:
|
|
51
|
+
"""Result of a semantic similarity query."""
|
|
52
|
+
|
|
53
|
+
facts: list[StateFact] = field(default_factory=list)
|
|
54
|
+
scores: dict[str, float] = field(default_factory=dict)
|
|
55
|
+
used_ann: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Cosine similarity
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cosine_sim(a: list[float], b: list[float]) -> float:
|
|
64
|
+
"""Compute cosine similarity between two vectors."""
|
|
65
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
66
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
67
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
68
|
+
if norm_a < 1e-12 or norm_b < 1e-12:
|
|
69
|
+
return 0.0
|
|
70
|
+
return dot / (norm_a * norm_b)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Adaptive top_k
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
MIN_TOP_K = 20
|
|
78
|
+
MAX_TOP_K = 200
|
|
79
|
+
ANN_THRESHOLD = 1000
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def adaptive_top_k(fact_count: int, base_k: int = MIN_TOP_K) -> int:
|
|
83
|
+
"""Scale top_k based on store size: 20 for small, up to 200 for large."""
|
|
84
|
+
if fact_count <= 100:
|
|
85
|
+
return base_k
|
|
86
|
+
# Linear scale between 100 and 10_000 facts
|
|
87
|
+
scale = min(1.0, (fact_count - 100) / 9900)
|
|
88
|
+
return min(MAX_TOP_K, base_k + int(scale * (MAX_TOP_K - base_k)))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# HNSW index wrapper
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class HNSWIndex:
|
|
97
|
+
"""Thin wrapper around hnswlib for ANN queries."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, dim: int, max_elements: int = 10_000) -> None:
|
|
100
|
+
lib = _check_hnswlib()
|
|
101
|
+
if lib is None:
|
|
102
|
+
raise RuntimeError("hnswlib not available")
|
|
103
|
+
self._lib = lib
|
|
104
|
+
self._dim = dim
|
|
105
|
+
self._max_elements = max_elements
|
|
106
|
+
self._index = lib.Index(space="cosine", dim=dim)
|
|
107
|
+
self._index.init_index(max_elements=max_elements, ef_construction=200, M=16)
|
|
108
|
+
self._index.set_ef(50)
|
|
109
|
+
self._id_map: dict[int, str] = {}
|
|
110
|
+
self._next_int: int = 0
|
|
111
|
+
|
|
112
|
+
def _resize_if_needed(self) -> None:
|
|
113
|
+
"""Double capacity when nearing the limit."""
|
|
114
|
+
if self._next_int >= self._max_elements:
|
|
115
|
+
self._max_elements *= 2
|
|
116
|
+
self._index.resize_index(self._max_elements)
|
|
117
|
+
|
|
118
|
+
def add(self, fact_id: str, embedding: list[float]) -> None:
|
|
119
|
+
self._resize_if_needed()
|
|
120
|
+
int_id = self._next_int
|
|
121
|
+
self._id_map[int_id] = fact_id
|
|
122
|
+
self._index.add_items([embedding], [int_id])
|
|
123
|
+
self._next_int += 1
|
|
124
|
+
|
|
125
|
+
def query(self, embedding: list[float], k: int) -> list[tuple[str, float]]:
|
|
126
|
+
"""Return [(fact_id, distance), ...] for top-k nearest."""
|
|
127
|
+
k = min(k, self._next_int)
|
|
128
|
+
if k == 0:
|
|
129
|
+
return []
|
|
130
|
+
labels, distances = self._index.knn_query([embedding], k=k)
|
|
131
|
+
results = []
|
|
132
|
+
for label, dist in zip(labels[0], distances[0]):
|
|
133
|
+
fid = self._id_map.get(int(label))
|
|
134
|
+
if fid:
|
|
135
|
+
# hnswlib cosine returns 1 - cos_sim
|
|
136
|
+
results.append((fid, 1.0 - float(dist)))
|
|
137
|
+
return results
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def count(self) -> int:
|
|
141
|
+
return self._next_int
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Brute-force fallback
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _brute_force_query(
|
|
150
|
+
query_embedding: list[float],
|
|
151
|
+
facts: dict[str, StateFact],
|
|
152
|
+
top_k: int,
|
|
153
|
+
) -> list[tuple[str, float]]:
|
|
154
|
+
"""Brute-force cosine similarity search."""
|
|
155
|
+
scored: list[tuple[str, float]] = []
|
|
156
|
+
for fid, sf in facts.items():
|
|
157
|
+
emb = sf.embedding
|
|
158
|
+
if emb is not None:
|
|
159
|
+
sim = _cosine_sim(query_embedding, emb)
|
|
160
|
+
scored.append((fid, sim))
|
|
161
|
+
scored.sort(key=lambda x: -x[1])
|
|
162
|
+
return scored[:top_k]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Public API
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def semantic_fallback(
|
|
171
|
+
query_embedding: list[float],
|
|
172
|
+
facts: dict[str, StateFact],
|
|
173
|
+
top_k: int | None = None,
|
|
174
|
+
hnsw_index: HNSWIndex | None = None,
|
|
175
|
+
) -> SemanticResult:
|
|
176
|
+
"""Retrieve the *top_k* most similar facts to *query_embedding*.
|
|
177
|
+
|
|
178
|
+
Uses HNSW index if provided and store is large enough; otherwise brute-force.
|
|
179
|
+
Adaptive top_k: 20→200 based on store size.
|
|
180
|
+
"""
|
|
181
|
+
if not query_embedding or not facts:
|
|
182
|
+
return SemanticResult()
|
|
183
|
+
|
|
184
|
+
k = top_k if top_k is not None else adaptive_top_k(len(facts))
|
|
185
|
+
used_ann = False
|
|
186
|
+
|
|
187
|
+
# Try HNSW if index provided and large enough
|
|
188
|
+
if hnsw_index is not None and hnsw_index.count >= ANN_THRESHOLD:
|
|
189
|
+
try:
|
|
190
|
+
raw = hnsw_index.query(query_embedding, k)
|
|
191
|
+
used_ann = True
|
|
192
|
+
except Exception: # noqa: BLE001
|
|
193
|
+
logger.warning("HNSW query failed, falling back to brute-force")
|
|
194
|
+
raw = _brute_force_query(query_embedding, facts, k)
|
|
195
|
+
else:
|
|
196
|
+
raw = _brute_force_query(query_embedding, facts, k)
|
|
197
|
+
|
|
198
|
+
# Build result
|
|
199
|
+
result_facts: list[StateFact] = []
|
|
200
|
+
scores: dict[str, float] = {}
|
|
201
|
+
for fid, sim in raw:
|
|
202
|
+
sf = facts.get(fid)
|
|
203
|
+
if sf and not sf.is_superseded:
|
|
204
|
+
result_facts.append(sf)
|
|
205
|
+
scores[fid] = sim
|
|
206
|
+
|
|
207
|
+
return SemanticResult(facts=result_facts, scores=scores, used_ann=used_ann)
|
crp/cli/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""CRP CLI — crp init, dispatch, ingest, status, preview, serve."""
|
|
4
|
+
|
|
5
|
+
from crp.cli.startup import StartupResult, StartupStep, run_startup
|
|
6
|
+
|
|
7
|
+
__all__ = ["StartupResult", "StartupStep", "run_startup"]
|