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
|
@@ -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
|
crp/envelope/reranker.py
ADDED
|
@@ -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
|