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
@@ -0,0 +1,168 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Phase 3G — Envelope serialization (§2.2).
4
+
5
+ Formats the envelope as plain text with [BRACKETED_CAPS] section markers.
6
+ Section priority tiers (03_ENVELOPE §2.2):
7
+
8
+ Tier 1 (always): [GOAL], [PHASE], [BLOCKER], [CONSTRAINT], [WINDOW]
9
+ Tier 2 (include when available): [LLM_SYNTHESIS], [TASK], [OUTPUT_FORMAT]
10
+ Tier 3 (adaptive): [DISCOVERIES], [SOURCE], [DECISIONS], [ERROR_LOG],
11
+ [TOOL_HISTORY], [EXPANDED: {label}], [KNOWLEDGE: {query}],
12
+ [KNOWLEDGE_GRAPH: {seed}], [KNOWLEDGE_COMMUNITY: {name}]
13
+ Tier 4 (weak models only): [REASONING APPROACH], [SIMILAR SOLVED EXAMPLES]
14
+
15
+ Fact format:
16
+ - {fact text}: {detail} — {source window/evidence}
17
+ ↳ [{RELATION_TYPE}] {target_fact_text}
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ from .packer import PackedFact
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Section definitions
28
+ # ---------------------------------------------------------------------------
29
+
30
+ # Priority tiers — lower number = higher priority
31
+ TIER_1_SECTIONS = ("GOAL", "PHASE", "BLOCKER", "CONSTRAINT", "WINDOW")
32
+ TIER_2_SECTIONS = ("LLM_SYNTHESIS", "TASK", "OUTPUT_FORMAT")
33
+ TIER_3_SECTIONS = (
34
+ "DISCOVERIES",
35
+ "SOURCE",
36
+ "DECISIONS",
37
+ "ERROR_LOG",
38
+ "TOOL_HISTORY",
39
+ )
40
+ # Tier 3 dynamic: EXPANDED, KNOWLEDGE, KNOWLEDGE_GRAPH, KNOWLEDGE_COMMUNITY
41
+ # Tier 4: REASONING APPROACH, SIMILAR SOLVED EXAMPLES
42
+
43
+
44
+ @dataclass
45
+ class EnvelopeSection:
46
+ """One section of the formatted envelope."""
47
+
48
+ name: str = "" # e.g. "GOAL", "EXPANDED: MySource"
49
+ content: str = ""
50
+ tier: int = 1
51
+ tokens: int = 0
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Section header formatting
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def _section_header(name: str) -> str:
60
+ """Format a [BRACKETED_CAPS] section header."""
61
+ return f"[{name.upper()}]"
62
+
63
+
64
+ def _classify_tier(name: str) -> int:
65
+ """Determine the tier of a section by its name."""
66
+ upper = name.upper()
67
+ if upper in TIER_1_SECTIONS:
68
+ return 1
69
+ if upper in TIER_2_SECTIONS:
70
+ return 2
71
+ if upper in TIER_3_SECTIONS:
72
+ return 3
73
+ if upper.startswith(("EXPANDED:", "KNOWLEDGE:", "KNOWLEDGE_GRAPH:", "KNOWLEDGE_COMMUNITY:")):
74
+ return 3
75
+ if upper in ("REASONING APPROACH", "SIMILAR SOLVED EXAMPLES"):
76
+ return 4
77
+ return 3 # default to tier 3 for unknown sections
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Format facts into [DISCOVERIES] section
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def format_facts_section(packed_facts: list[PackedFact]) -> str:
86
+ """Format packed facts into the body of the [DISCOVERIES] section.
87
+
88
+ Separates bookend facts with a marker comment.
89
+ """
90
+ main_facts: list[str] = []
91
+ bookend_facts: list[str] = []
92
+
93
+ for pf in packed_facts:
94
+ if pf.is_bookend:
95
+ bookend_facts.append(pf.text)
96
+ else:
97
+ main_facts.append(pf.text)
98
+
99
+ parts: list[str] = []
100
+ if main_facts:
101
+ parts.extend(main_facts)
102
+ if bookend_facts:
103
+ parts.append("") # blank line before bookend
104
+ parts.append("--- Key facts (reinforced) ---")
105
+ parts.extend(bookend_facts)
106
+
107
+ return "\n".join(parts)
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Main formatting function
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def format_envelope(
116
+ sections: dict[str, str],
117
+ packed_facts: list[PackedFact] | None = None,
118
+ ) -> str:
119
+ """Format *sections* into the final envelope text.
120
+
121
+ Parameters
122
+ ----------
123
+ sections : dict[str, str]
124
+ Section name → content text. Names should be upper-case
125
+ (e.g. ``{"GOAL": "Analyse CVE...", "PHASE": "scanning"}``).
126
+ packed_facts : list[PackedFact] | None
127
+ If provided, formats and inserts as the [DISCOVERIES] section.
128
+
129
+ Returns
130
+ -------
131
+ str
132
+ Plain-text envelope: ``[SECTION_NAME]\\ncontent\\n\\n...``
133
+ """
134
+ # Build section objects with tier classification
135
+ envelope_sections: list[EnvelopeSection] = []
136
+
137
+ for name, content in sections.items():
138
+ if content and content.strip():
139
+ envelope_sections.append(
140
+ EnvelopeSection(
141
+ name=name,
142
+ content=content.strip(),
143
+ tier=_classify_tier(name),
144
+ )
145
+ )
146
+
147
+ # Add packed facts as DISCOVERIES if not already provided
148
+ if packed_facts and "DISCOVERIES" not in sections:
149
+ facts_text = format_facts_section(packed_facts)
150
+ if facts_text.strip():
151
+ envelope_sections.append(
152
+ EnvelopeSection(
153
+ name="DISCOVERIES",
154
+ content=facts_text,
155
+ tier=3,
156
+ )
157
+ )
158
+
159
+ # Sort by tier (lower = higher priority), then preserve insertion order within tier
160
+ envelope_sections.sort(key=lambda s: s.tier)
161
+
162
+ # Serialize
163
+ parts: list[str] = []
164
+ for sec in envelope_sections:
165
+ header = _section_header(sec.name)
166
+ parts.append(f"{header}\n{sec.content}")
167
+
168
+ return "\n\n".join(parts)
crp/envelope/packer.py ADDED
@@ -0,0 +1,211 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Phase 4+5 — Graph-aware packing & bookend strategy (§3.2).
4
+
5
+ Greedy packing: sort by score, pack while token budget remains.
6
+ Graph neighbour pulling: 2-hop BFS, indented sub-lines.
7
+ Compressed fact fallback: if >50 tokens remain but no full fact fits.
8
+ Bookend strategy: duplicate top-3 scored facts at END of envelope.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from crp.extraction.types import FactGraph, RelationType
17
+
18
+ from .scoring import ScoredFact
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Token estimation
22
+ # ---------------------------------------------------------------------------
23
+
24
+ # Default chars-per-token ratio for English BPE models.
25
+ # The spec PROHIBITS hardcoded //4 but ALLOWS a calibrated estimator.
26
+ # This default (3.3 chars/token) is conservative for most English BPE models.
27
+ _DEFAULT_CHARS_PER_TOKEN = 3.3
28
+
29
+
30
+ def estimate_tokens(text: str, chars_per_token: float = _DEFAULT_CHARS_PER_TOKEN) -> int:
31
+ """Estimate token count from text length using calibrated ratio.
32
+
33
+ This is used as a fallback when no model tokenizer is available.
34
+ When an actual tokenizer is provided via ``count_tokens``, use that instead.
35
+ """
36
+ if not text:
37
+ return 0
38
+ return max(1, int(len(text) / chars_per_token + 0.5))
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Packing result
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ @dataclass
47
+ class PackedFact:
48
+ """A fact selected for the envelope, with its formatted text."""
49
+
50
+ fact_id: str = ""
51
+ text: str = ""
52
+ score: float = 0.0
53
+ tokens: int = 0
54
+ is_neighbour: bool = False
55
+ is_compressed: bool = False
56
+ is_bookend: bool = False
57
+
58
+
59
+ @dataclass
60
+ class PackingResult:
61
+ """Output of the packing phase."""
62
+
63
+ packed_facts: list[PackedFact] = field(default_factory=list)
64
+ total_tokens: int = 0
65
+ budget: int = 0
66
+ facts_considered: int = 0
67
+ facts_packed: int = 0
68
+ compressed_count: int = 0
69
+ bookend_count: int = 0
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Graph neighbour formatting
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def _format_neighbours(
77
+ fact_id: str,
78
+ graph: FactGraph,
79
+ max_hops: int = 2,
80
+ ) -> list[str]:
81
+ """BFS neighbours up to *max_hops*, formatted as indented sub-lines."""
82
+ sub = graph.subgraph_for({fact_id}, max_hops=max_hops)
83
+ lines: list[str] = []
84
+ for edge in sub.edges:
85
+ if edge.source_id == fact_id:
86
+ target = sub.nodes.get(edge.target_id)
87
+ if target:
88
+ rel = edge.relation_type
89
+ if isinstance(rel, RelationType):
90
+ rel = rel.value
91
+ lines.append(f" ↳ [{rel}] {target.text}")
92
+ return lines
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Compressed fact (truncation)
97
+ # ---------------------------------------------------------------------------
98
+
99
+ def _compress_fact(text: str, max_tokens: int, chars_per_token: float) -> str:
100
+ """Truncate *text* to fit within *max_tokens*."""
101
+ max_chars = int(max_tokens * chars_per_token)
102
+ if len(text) <= max_chars:
103
+ return text
104
+ return text[: max_chars - 3] + "..."
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Main packing function
109
+ # ---------------------------------------------------------------------------
110
+
111
+ BOOKEND_COUNT = 3
112
+ MIN_REMAINING_FOR_COMPRESSED = 50
113
+
114
+
115
+ def pack_facts(
116
+ scored_facts: list[ScoredFact],
117
+ graph: FactGraph,
118
+ budget_tokens: int,
119
+ *,
120
+ count_tokens: Any = None,
121
+ chars_per_token: float = _DEFAULT_CHARS_PER_TOKEN,
122
+ ) -> PackingResult:
123
+ """Greedily pack *scored_facts* into the envelope token budget.
124
+
125
+ Parameters
126
+ ----------
127
+ scored_facts : list[ScoredFact]
128
+ Pre-sorted by composite score (descending).
129
+ graph : FactGraph
130
+ Fact graph for 2-hop neighbour pulling.
131
+ budget_tokens : int
132
+ Maximum tokens available for the facts section.
133
+ count_tokens : callable | None
134
+ Actual tokenizer function ``(str) → int``. If None, uses estimate.
135
+ chars_per_token : float
136
+ Calibrated chars/token for the estimator fallback.
137
+ """
138
+ if not scored_facts or budget_tokens <= 0:
139
+ return PackingResult(budget=budget_tokens, facts_considered=len(scored_facts))
140
+
141
+ def _count(text: str) -> int:
142
+ if count_tokens is not None:
143
+ return count_tokens(text)
144
+ return estimate_tokens(text, chars_per_token)
145
+
146
+ result = PackingResult(budget=budget_tokens, facts_considered=len(scored_facts))
147
+ used_tokens = 0
148
+ packed_ids: set[str] = set()
149
+
150
+ # Phase 4: Greedy packing with graph-neighbour pulling
151
+ for sf in scored_facts:
152
+ fact_line = f"- {sf.fact.text}"
153
+ neighbour_lines = _format_neighbours(sf.fact.id, graph)
154
+ full_text = fact_line
155
+ if neighbour_lines:
156
+ full_text = fact_line + "\n" + "\n".join(neighbour_lines)
157
+
158
+ fact_tokens = _count(full_text)
159
+
160
+ if used_tokens + fact_tokens <= budget_tokens:
161
+ pf = PackedFact(
162
+ fact_id=sf.fact.id,
163
+ text=full_text,
164
+ score=sf.composite_score,
165
+ tokens=fact_tokens,
166
+ )
167
+ result.packed_facts.append(pf)
168
+ packed_ids.add(sf.fact.id)
169
+ used_tokens += fact_tokens
170
+ continue
171
+
172
+ # Compressed fallback: if >50 tokens remain, truncate this fact to fit
173
+ remaining = budget_tokens - used_tokens
174
+ if remaining >= MIN_REMAINING_FOR_COMPRESSED:
175
+ compressed_text = _compress_fact(fact_line, remaining, chars_per_token)
176
+ ct = _count(compressed_text)
177
+ if ct <= remaining:
178
+ pf = PackedFact(
179
+ fact_id=sf.fact.id,
180
+ text=compressed_text,
181
+ score=sf.composite_score,
182
+ tokens=ct,
183
+ is_compressed=True,
184
+ )
185
+ result.packed_facts.append(pf)
186
+ packed_ids.add(sf.fact.id)
187
+ used_tokens += ct
188
+ result.compressed_count += 1
189
+ break # budget exhausted after compressed fit attempt
190
+ else:
191
+ break # not enough room even for compression
192
+
193
+ # Phase 5: Bookend strategy — duplicate top-3 facts at END
194
+ bookend_candidates = [pf for pf in result.packed_facts if not pf.is_compressed][:BOOKEND_COUNT]
195
+ for pf in bookend_candidates:
196
+ bookend_tokens = _count(pf.text)
197
+ if used_tokens + bookend_tokens <= budget_tokens:
198
+ bpf = PackedFact(
199
+ fact_id=pf.fact_id,
200
+ text=pf.text,
201
+ score=pf.score,
202
+ tokens=bookend_tokens,
203
+ is_bookend=True,
204
+ )
205
+ result.packed_facts.append(bpf)
206
+ used_tokens += bookend_tokens
207
+ result.bookend_count += 1
208
+
209
+ result.total_tokens = used_tokens
210
+ result.facts_packed = len(packed_ids)
211
+ return result
@@ -0,0 +1,209 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Phase 3 — Cross-encoder reranking (§3.2).
4
+
5
+ Reranks the top-K bi-encoder scored facts using a cross-encoder for higher
6
+ accuracy. Falls back to bi-encoder scores when the cross-encoder model is
7
+ unavailable.
8
+
9
+ Blended score: 0.6 × CE_score + 0.4 × bi_encoder_score
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ from dataclasses import dataclass, field
16
+
17
+ from crp.core.task_intent import TaskIntent
18
+
19
+ from .scoring import ScoredFact
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Constants
23
+ # ---------------------------------------------------------------------------
24
+
25
+ TOP_K_RERANK = 200
26
+ MIN_FACTS_FOR_RERANK = 10
27
+ CE_WEIGHT = 0.6
28
+ BI_WEIGHT = 0.4
29
+ IDLE_UNLOAD_WINDOWS = 10
30
+ TASK_SIM_INVALIDATION_THRESHOLD = 0.9
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Cross-encoder model management (lazy load, idle unload)
34
+ # ---------------------------------------------------------------------------
35
+
36
+ _CE_MODEL: object | None = None
37
+ _CE_AVAILABLE: bool | None = None # None = not yet attempted
38
+ _CE_LAST_USED_WINDOW: int = 0
39
+
40
+
41
+ def _try_load_cross_encoder() -> bool:
42
+ """Attempt lazy load of cross-encoder model. Returns True on success."""
43
+ global _CE_MODEL, _CE_AVAILABLE # noqa: PLW0603
44
+ if _CE_AVAILABLE is True:
45
+ return True
46
+ if _CE_AVAILABLE is False:
47
+ return False
48
+ try:
49
+ from sentence_transformers import CrossEncoder # type: ignore[import-untyped]
50
+
51
+ _CE_MODEL = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")
52
+ _CE_AVAILABLE = True
53
+ except Exception: # noqa: BLE001
54
+ _CE_AVAILABLE = False
55
+ return _CE_AVAILABLE or False
56
+
57
+
58
+ def maybe_unload_cross_encoder(current_window: int) -> None:
59
+ """Unload cross-encoder if idle for > IDLE_UNLOAD_WINDOWS."""
60
+ global _CE_MODEL, _CE_AVAILABLE, _CE_LAST_USED_WINDOW # noqa: PLW0603
61
+ if _CE_MODEL is not None and (current_window - _CE_LAST_USED_WINDOW) > IDLE_UNLOAD_WINDOWS:
62
+ _CE_MODEL = None
63
+ _CE_AVAILABLE = None
64
+
65
+
66
+ def preload_cross_encoder() -> bool:
67
+ """Eagerly load the cross-encoder model at startup to avoid cold-start delay.
68
+
69
+ Returns True if the model was loaded successfully, False otherwise.
70
+ Called from CRPOrchestrator.__init__() to pay the ~5s load cost once
71
+ at protocol start rather than during the first rerank operation.
72
+ """
73
+ return _try_load_cross_encoder()
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # CrossEncoderCache
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def _task_hash(task_intent: TaskIntent) -> str:
82
+ """Deterministic hash of the task for cache keying."""
83
+ parts = (task_intent.system_prompt or "") + "|" + (task_intent.task_input or "")
84
+ return hashlib.sha256(parts.encode()).hexdigest()[:16]
85
+
86
+
87
+ @dataclass
88
+ class CrossEncoderCache:
89
+ """Per-session cache for cross-encoder scores.
90
+
91
+ Key: (task_hash, fact_id). Invalidation rules:
92
+ - Full clear on compaction.
93
+ - Remove entry on fact supersession.
94
+ - Full clear if task similarity < 0.9 (compared via hash change).
95
+ """
96
+
97
+ _cache: dict[tuple[str, str], float] = field(default_factory=dict)
98
+ _last_task_hash: str = ""
99
+
100
+ def get(self, t_hash: str, fact_id: str) -> float | None:
101
+ return self._cache.get((t_hash, fact_id))
102
+
103
+ def put(self, t_hash: str, fact_id: str, score: float) -> None:
104
+ self._cache[(t_hash, fact_id)] = score
105
+
106
+ def invalidate_fact(self, fact_id: str) -> None:
107
+ """Remove all entries for a superseded fact."""
108
+ keys = [k for k in self._cache if k[1] == fact_id]
109
+ for k in keys:
110
+ del self._cache[k]
111
+
112
+ def invalidate_all(self) -> None:
113
+ """Full cache clear (compaction or task change)."""
114
+ self._cache.clear()
115
+ self._last_task_hash = ""
116
+
117
+ def check_task_change(self, new_task_hash: str) -> None:
118
+ """If task hash changed, invalidate entire cache."""
119
+ if self._last_task_hash and self._last_task_hash != new_task_hash:
120
+ self.invalidate_all()
121
+ self._last_task_hash = new_task_hash
122
+
123
+ @property
124
+ def size(self) -> int:
125
+ return len(self._cache)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Reranking
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def rerank(
134
+ scored_facts: list[ScoredFact],
135
+ task_intent: TaskIntent,
136
+ *,
137
+ cache: CrossEncoderCache | None = None,
138
+ current_window: int = 0,
139
+ ) -> list[ScoredFact]:
140
+ """Rerank *scored_facts* using the cross-encoder.
141
+
142
+ - Only top TOP_K_RERANK facts are reranked.
143
+ - If fewer than MIN_FACTS_FOR_RERANK facts, skip reranking entirely.
144
+ - Falls back to bi-encoder scores if the model is unavailable.
145
+
146
+ Returns all facts sorted by blended score descending.
147
+ """
148
+ global _CE_LAST_USED_WINDOW # noqa: PLW0603
149
+
150
+ if not scored_facts:
151
+ return []
152
+
153
+ # If too few facts, skip reranking — bi-encoder is sufficient
154
+ if len(scored_facts) < MIN_FACTS_FOR_RERANK:
155
+ return scored_facts # already sorted by bi-encoder score
156
+
157
+ # Select candidates for reranking
158
+ candidates = scored_facts[:TOP_K_RERANK]
159
+ remainder = scored_facts[TOP_K_RERANK:]
160
+
161
+ # Attempt cross-encoder
162
+ if not _try_load_cross_encoder() or _CE_MODEL is None:
163
+ return scored_facts # fallback: bi-encoder order preserved
164
+
165
+ _CE_LAST_USED_WINDOW = current_window
166
+
167
+ t_hash = _task_hash(task_intent)
168
+ if cache is not None:
169
+ cache.check_task_change(t_hash)
170
+
171
+ # Build query text
172
+ query = (task_intent.system_prompt or "") + " " + (task_intent.task_input or "")
173
+ query = query.strip()
174
+
175
+ # Score pairs (using cache where available)
176
+ pairs_to_score: list[tuple[int, str]] = [] # (index_in_candidates, fact_text)
177
+ ce_scores: dict[int, float] = {}
178
+
179
+ for i, sf in enumerate(candidates):
180
+ cached = cache.get(t_hash, sf.fact.id) if cache else None
181
+ if cached is not None:
182
+ ce_scores[i] = cached
183
+ else:
184
+ pairs_to_score.append((i, sf.fact.text))
185
+
186
+ # Batch predict uncached pairs
187
+ if pairs_to_score:
188
+ input_pairs = [(query, text) for _, text in pairs_to_score]
189
+ try:
190
+ raw_scores = _CE_MODEL.predict(input_pairs) # type: ignore[attr-defined]
191
+ for (idx, _), raw in zip(pairs_to_score, raw_scores):
192
+ score_val = float(raw)
193
+ ce_scores[idx] = score_val
194
+ if cache is not None:
195
+ cache.put(t_hash, candidates[idx].fact.id, score_val)
196
+ except Exception: # noqa: BLE001
197
+ # Model failure — fall back to bi-encoder scores
198
+ return scored_facts
199
+
200
+ # Compute blended scores
201
+ for i, sf in enumerate(candidates):
202
+ ce_score = ce_scores.get(i, 0.0)
203
+ sf.composite_score = CE_WEIGHT * ce_score + BI_WEIGHT * sf.composite_score
204
+
205
+ # Re-sort candidates by blended score
206
+ candidates.sort(key=lambda s: s.composite_score, reverse=True)
207
+
208
+ # Append remainder (already below top-K, keep original order)
209
+ return candidates + remainder