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.
Files changed (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. 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
+ )
@@ -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"]