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,30 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Envelope construction — 6-phase algorithm, scoring, packing, formatting."""
4
+
5
+ from .builder import EnvelopeResult, EnvelopeState, compute_envelope_budget, construct
6
+ from .decomposer import DecompositionResult, decompose_task_aspects
7
+ from .formatter import EnvelopeSection, format_envelope
8
+ from .packer import PackedFact, PackingResult, estimate_tokens, pack_facts
9
+ from .reranker import CrossEncoderCache, rerank
10
+ from .scoring import ScoredFact, ScoringConfig, score_facts
11
+
12
+ __all__ = [
13
+ "CrossEncoderCache",
14
+ "DecompositionResult",
15
+ "EnvelopeResult",
16
+ "EnvelopeSection",
17
+ "EnvelopeState",
18
+ "PackedFact",
19
+ "PackingResult",
20
+ "ScoredFact",
21
+ "ScoringConfig",
22
+ "compute_envelope_budget",
23
+ "construct",
24
+ "decompose_task_aspects",
25
+ "estimate_tokens",
26
+ "format_envelope",
27
+ "pack_facts",
28
+ "rerank",
29
+ "score_facts",
30
+ ]
@@ -0,0 +1,288 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Envelope builder — top-level 6-phase construction orchestrator (§3.2).
4
+
5
+ ``construct(task_intent, budget, state)`` returns the final envelope text.
6
+
7
+ 6-Phase algorithm:
8
+ Phase 1: Multi-aspect task decomposition → decomposer.py
9
+ Phase 2: Bi-encoder scoring → scoring.py
10
+ Phase 3: Cross-encoder reranking → reranker.py
11
+ Phase 4: Graph-aware packing → packer.py
12
+ Phase 5: Bookend strategy → packer.py
13
+ Phase 6: CKF retrieval gate → this module
14
+
15
+ Envelope budget formula (02_CORE §2.1):
16
+ E_max = C − S − T − G
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import time
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass, field
25
+
26
+ logger = logging.getLogger("crp.envelope.builder")
27
+
28
+ from crp.core.task_intent import TaskIntent
29
+ from crp.extraction.types import Fact, FactGraph
30
+
31
+ from .decomposer import DecompositionResult, decompose_task_aspects
32
+ from .formatter import format_envelope
33
+ from .packer import PackingResult, estimate_tokens, pack_facts
34
+ from .reranker import CrossEncoderCache, rerank
35
+ from .scoring import ScoringConfig, score_facts
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Envelope state — holds facts, graph, and per-session metadata
39
+ # ---------------------------------------------------------------------------
40
+
41
+ CKF_GATE_TOKENS = 120 # min remaining tokens before pulling from CKF
42
+ CKF_RESERVE_RATIO = 0.15 # fraction of fact_budget reserved for CKF when retriever is available
43
+
44
+
45
+ @dataclass
46
+ class EnvelopeState:
47
+ """Session-scoped state passed to the builder."""
48
+
49
+ # Fact store (warm tier)
50
+ facts: list[Fact] = field(default_factory=list)
51
+ graph: FactGraph = field(default_factory=FactGraph)
52
+
53
+ # Metadata for scoring
54
+ current_window_index: int = 0
55
+ seen_counts: dict[str, int] = field(default_factory=dict)
56
+ fact_window_indices: dict[str, int] = field(default_factory=dict)
57
+
58
+ # Critical state sections (pre-populated by state management)
59
+ sections: dict[str, str] = field(default_factory=dict)
60
+
61
+ # CKF callback — if registered, called when budget allows
62
+ ckf_retriever: Callable[[str, int], list[Fact]] | None = None
63
+
64
+ # Cross-encoder cache (shared across windows)
65
+ ce_cache: CrossEncoderCache = field(default_factory=CrossEncoderCache)
66
+
67
+ # Scoring config override
68
+ scoring_config: ScoringConfig | None = None
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Build result
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ @dataclass
77
+ class EnvelopeResult:
78
+ """Output of ``construct()``."""
79
+
80
+ envelope_text: str = ""
81
+ envelope_tokens: int = 0
82
+ budget_tokens: int = 0
83
+ saturation: float = 0.0 # envelope_tokens / budget_tokens
84
+ facts_included: int = 0
85
+ facts_available: int = 0
86
+ bookend_count: int = 0
87
+ compressed_count: int = 0
88
+ ckf_facts_added: int = 0
89
+ latency_ms: float = 0.0
90
+ decomposition: DecompositionResult | None = None
91
+ packing: PackingResult | None = None
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Envelope budget computation
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def compute_envelope_budget(
100
+ context_window: int,
101
+ system_tokens: int,
102
+ task_tokens: int,
103
+ generation_reserve: int | None = None,
104
+ max_output_tokens: int | None = None,
105
+ ) -> int:
106
+ """E_max = C − S − T − G.
107
+
108
+ Generation reserve precedence:
109
+ 1. User-specified max_output_tokens
110
+ 2. Explicit generation_reserve
111
+ 3. Default: min(C // 4, 16384)
112
+ """
113
+ if max_output_tokens is not None:
114
+ g = max_output_tokens
115
+ elif generation_reserve is not None:
116
+ g = generation_reserve
117
+ else:
118
+ g = min(context_window // 4, 16384)
119
+ return max(0, context_window - system_tokens - task_tokens - g)
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Main entry point
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def construct(
128
+ task_intent: TaskIntent,
129
+ budget_tokens: int,
130
+ state: EnvelopeState,
131
+ *,
132
+ count_tokens: Callable[[str], int] | None = None,
133
+ chars_per_token: float = 3.3,
134
+ ) -> EnvelopeResult:
135
+ """Build an envelope for *task_intent* within *budget_tokens*.
136
+
137
+ This is the top-level orchestrator implementing the 6-phase algorithm.
138
+
139
+ Parameters
140
+ ----------
141
+ task_intent : TaskIntent
142
+ The task to assemble context for.
143
+ budget_tokens : int
144
+ Maximum envelope tokens (E_max from budget formula).
145
+ state : EnvelopeState
146
+ Session-scoped state containing facts, graph, sections, etc.
147
+ count_tokens : callable | None
148
+ Model tokenizer function ``(str) → int``. Falls back to estimator.
149
+ chars_per_token : float
150
+ Calibrated chars/token ratio for fallback estimation.
151
+ """
152
+ t0 = time.perf_counter()
153
+
154
+ def _count(text: str) -> int:
155
+ if count_tokens is not None:
156
+ return count_tokens(text)
157
+ return estimate_tokens(text, chars_per_token)
158
+
159
+ result = EnvelopeResult(
160
+ budget_tokens=budget_tokens,
161
+ facts_available=len(state.facts),
162
+ )
163
+
164
+ if budget_tokens <= 0:
165
+ result.latency_ms = (time.perf_counter() - t0) * 1000
166
+ return result
167
+
168
+ # -----------------------------------------------------------------------
169
+ # Phase 1: Multi-aspect task decomposition
170
+ # -----------------------------------------------------------------------
171
+ decomposition = decompose_task_aspects(task_intent)
172
+ result.decomposition = decomposition
173
+
174
+ # -----------------------------------------------------------------------
175
+ # Phase 2: Bi-encoder scoring
176
+ # -----------------------------------------------------------------------
177
+ scored = score_facts(
178
+ state.facts,
179
+ decomposition,
180
+ state.graph,
181
+ current_window_index=state.current_window_index,
182
+ seen_counts=state.seen_counts,
183
+ fact_window_indices=state.fact_window_indices,
184
+ config=state.scoring_config,
185
+ )
186
+
187
+ # -----------------------------------------------------------------------
188
+ # Phase 3: Cross-encoder reranking
189
+ # -----------------------------------------------------------------------
190
+ scored = rerank(
191
+ scored,
192
+ task_intent,
193
+ cache=state.ce_cache,
194
+ current_window=state.current_window_index,
195
+ )
196
+
197
+ # -----------------------------------------------------------------------
198
+ # Compute section overhead (tier 1-2 sections that always appear)
199
+ # -----------------------------------------------------------------------
200
+ section_text_parts: dict[str, str] = {}
201
+ section_overhead = 0
202
+ for name, content in state.sections.items():
203
+ if content and content.strip():
204
+ header = f"[{name.upper()}]\n{content.strip()}"
205
+ section_overhead += _count(header) + 2 # +2 for "\n\n"
206
+ section_text_parts[name] = content.strip()
207
+
208
+ # Budget remaining for facts
209
+ fact_budget = max(0, budget_tokens - section_overhead)
210
+
211
+ # Reserve budget for CKF when retriever is available (§audit fix G1)
212
+ ckf_reserve = 0
213
+ if state.ckf_retriever is not None and fact_budget > CKF_GATE_TOKENS * 2:
214
+ ckf_reserve = max(CKF_GATE_TOKENS, int(fact_budget * CKF_RESERVE_RATIO))
215
+ warm_budget = fact_budget - ckf_reserve
216
+
217
+ # -----------------------------------------------------------------------
218
+ # Phase 4+5: Graph-aware packing + bookend strategy
219
+ # -----------------------------------------------------------------------
220
+ packing = pack_facts(
221
+ scored,
222
+ state.graph,
223
+ warm_budget,
224
+ count_tokens=count_tokens,
225
+ chars_per_token=chars_per_token,
226
+ )
227
+ result.packing = packing
228
+ result.facts_included = packing.facts_packed
229
+ result.bookend_count = packing.bookend_count
230
+ result.compressed_count = packing.compressed_count
231
+
232
+ # -----------------------------------------------------------------------
233
+ # Phase 6: CKF retrieval gate
234
+ # -----------------------------------------------------------------------
235
+ # CKF gets reserved budget + any unused warm store budget
236
+ remaining_for_ckf = ckf_reserve + (warm_budget - packing.total_tokens)
237
+ if (
238
+ remaining_for_ckf > CKF_GATE_TOKENS
239
+ and state.ckf_retriever is not None
240
+ and decomposition.aspects
241
+ ):
242
+ query = " ".join(decomposition.aspects[:3])
243
+ try:
244
+ ckf_facts = state.ckf_retriever(query, remaining_for_ckf)
245
+ if ckf_facts:
246
+ # Score and pack CKF facts into remaining budget
247
+ ckf_scored = score_facts(
248
+ ckf_facts,
249
+ decomposition,
250
+ state.graph,
251
+ current_window_index=state.current_window_index,
252
+ config=state.scoring_config,
253
+ )
254
+ ckf_packed = pack_facts(
255
+ ckf_scored,
256
+ state.graph,
257
+ remaining_for_ckf,
258
+ count_tokens=count_tokens,
259
+ chars_per_token=chars_per_token,
260
+ )
261
+ # Merge CKF results
262
+ packing.packed_facts.extend(ckf_packed.packed_facts)
263
+ packing.total_tokens += ckf_packed.total_tokens
264
+ result.ckf_facts_added = ckf_packed.facts_packed
265
+ except Exception: # noqa: BLE001
266
+ logger.warning("CKF lookup failed during envelope build", exc_info=True)
267
+
268
+ # -----------------------------------------------------------------------
269
+ # Serialization
270
+ # -----------------------------------------------------------------------
271
+ envelope_text = format_envelope(section_text_parts, packing.packed_facts)
272
+
273
+ result.envelope_text = envelope_text
274
+ result.envelope_tokens = _count(envelope_text)
275
+
276
+ # Clamp envelope to budget if it overflows (§audit M9)
277
+ if budget_tokens > 0 and result.envelope_tokens > budget_tokens:
278
+ # Truncate envelope text to fit within budget
279
+ target_chars = int(budget_tokens * chars_per_token)
280
+ if len(envelope_text) > target_chars:
281
+ result.envelope_text = envelope_text[:target_chars]
282
+ result.envelope_tokens = _count(result.envelope_text)
283
+
284
+ if budget_tokens > 0:
285
+ result.saturation = min(1.0, result.envelope_tokens / budget_tokens)
286
+ result.latency_ms = (time.perf_counter() - t0) * 1000
287
+
288
+ return result
@@ -0,0 +1,236 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Phase 1 — Multi-aspect task decomposition (§3.2).
4
+
5
+ Decomposes TaskIntent into explicit + implicit aspects, each with an embedding
6
+ vector. Falls back to lightweight word-overlap vectors when sentence-transformers
7
+ is unavailable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import math
14
+ import re
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass, field
17
+
18
+ from crp.core.task_intent import TaskIntent
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Embedding abstraction — lightweight fallback when ML deps absent
22
+ # ---------------------------------------------------------------------------
23
+
24
+ # Sentinel that indicates sentence-transformers is available
25
+ _ST_AVAILABLE: bool = False
26
+ _EMBED_MODEL: object | None = None
27
+ _EMBED_DIM: int = 384 # all-MiniLM-L6-v2 output dim
28
+
29
+
30
+ def _try_load_sentence_transformer() -> bool:
31
+ """Attempt lazy load of sentence-transformers model. Returns True on success."""
32
+ global _ST_AVAILABLE, _EMBED_MODEL # noqa: PLW0603
33
+ if _ST_AVAILABLE:
34
+ return True
35
+ try:
36
+ from sentence_transformers import SentenceTransformer # type: ignore[import-untyped]
37
+
38
+ _EMBED_MODEL = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
39
+ _ST_AVAILABLE = True
40
+ except Exception: # noqa: BLE001
41
+ pass
42
+ return _ST_AVAILABLE
43
+
44
+
45
+ def get_embedding_fn() -> "Callable[[str], list[float]] | None":
46
+ """Return a text→embedding function using the loaded model, or None.
47
+
48
+ This is the canonical embedding function for the CRP pipeline.
49
+ Used by: set_embedding_function(), gap_analysis(), CKF semantic mode.
50
+ """
51
+ if not _try_load_sentence_transformer() or _EMBED_MODEL is None:
52
+ return None
53
+ model = _EMBED_MODEL
54
+
55
+ def _embed(text: str) -> list[float]:
56
+ return model.encode([text], show_progress_bar=False).tolist()[0] # type: ignore[attr-defined]
57
+
58
+ return _embed
59
+
60
+
61
+ # -- lightweight fallback vectors (word-overlap, normalised) -----------------
62
+
63
+ _STOP = frozenset(
64
+ ["a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "is", "it", "its", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would", "shall", "should", "may", "might", "can", "could", "this", "that", "these", "those", "i", "we", "you", "he", "she", "they", "me", "us", "him", "her", "them", "my", "our", "your", "his", "its", "their", "with", "from", "by", "as", "not", "no"]
65
+ )
66
+
67
+
68
+ def _tokenize(text: str) -> list[str]:
69
+ """Lower-case word tokeniser (alphanum only)."""
70
+ return [w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in _STOP]
71
+
72
+
73
+ def _bag_vector(tokens: Sequence[str], vocab: dict[str, int], dim: int) -> list[float]:
74
+ """Sparse bag-of-words vector projected to *dim* via hashing."""
75
+ vec = [0.0] * dim
76
+ for tok in tokens:
77
+ idx = vocab.get(tok)
78
+ if idx is None:
79
+ idx = int(hashlib.sha256(tok.encode()).hexdigest(), 16) % dim
80
+ vec[idx] += 1.0
81
+ # L2 normalise
82
+ norm = math.sqrt(sum(v * v for v in vec)) or 1.0
83
+ return [v / norm for v in vec]
84
+
85
+
86
+ def _build_vocab(all_tokens: list[str]) -> dict[str, int]:
87
+ """Deterministic vocabulary mapping (ordered by first occurrence)."""
88
+ vocab: dict[str, int] = {}
89
+ for tok in all_tokens:
90
+ if tok not in vocab:
91
+ vocab[tok] = len(vocab) % _EMBED_DIM
92
+ return vocab
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Public result dataclass
97
+ # ---------------------------------------------------------------------------
98
+
99
+ @dataclass
100
+ class DecompositionResult:
101
+ """Output of ``decompose_task_aspects``."""
102
+
103
+ aspects: list[str] = field(default_factory=list)
104
+ aspect_embeddings: list[list[float]] = field(default_factory=list)
105
+ full_embedding: list[float] = field(default_factory=list)
106
+ used_ml_model: bool = False
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Noun-phrase extraction (lightweight, no ML)
111
+ # ---------------------------------------------------------------------------
112
+
113
+ # Simple regex pattern for noun-phrase-like chunks: sequences of adjectives/nouns
114
+ _NP_PATTERN = re.compile(
115
+ r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b" # Capitalised phrases
116
+ r"|"
117
+ r"\b([a-z]+(?:[-_][a-z]+)+)\b" # Hyphenated/underscored compound terms
118
+ )
119
+
120
+
121
+ def _extract_noun_phrases(text: str) -> list[str]:
122
+ """Extract candidate noun phrases from *text*."""
123
+ phrases: list[str] = []
124
+ for m in _NP_PATTERN.finditer(text):
125
+ phrase = (m.group(1) or m.group(2) or "").strip()
126
+ if phrase and len(phrase) > 2:
127
+ phrases.append(phrase)
128
+ # Also grab quoted strings as explicit aspects
129
+ for m in re.finditer(r'"([^"]{3,80})"', text):
130
+ phrases.append(m.group(1))
131
+ # Deduplicate preserving order
132
+ seen: set[str] = set()
133
+ result: list[str] = []
134
+ for p in phrases:
135
+ key = p.lower()
136
+ if key not in seen:
137
+ seen.add(key)
138
+ result.append(p)
139
+ return result
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Implicit aspect expansion (word-based when no embeddings)
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def _expand_aspects_implicit(aspects: list[str], full_text: str) -> list[str]:
147
+ """Add implicit aspects derived from the full text that aren't in *aspects*.
148
+
149
+ When ML embeddings are unavailable, uses word co-occurrence: finds bigrams
150
+ from the full text that share a word with an existing aspect.
151
+ """
152
+ if not aspects:
153
+ # Fallback: use first N significant tokens as aspects
154
+ tokens = _tokenize(full_text)
155
+ # Pick unique tokens in order, up to 5
156
+ seen: set[str] = set()
157
+ result: list[str] = []
158
+ for t in tokens:
159
+ if t not in seen and len(t) > 2:
160
+ seen.add(t)
161
+ result.append(t)
162
+ if len(result) >= 5:
163
+ break
164
+ return result
165
+
166
+ aspect_words = set()
167
+ for a in aspects:
168
+ aspect_words.update(_tokenize(a))
169
+
170
+ words = _tokenize(full_text)
171
+ new_aspects: list[str] = []
172
+ seen_lower = {a.lower() for a in aspects}
173
+ for i in range(len(words) - 1):
174
+ bigram = f"{words[i]} {words[i + 1]}"
175
+ if bigram.lower() not in seen_lower:
176
+ # At least one word overlaps with existing aspects
177
+ if words[i] in aspect_words or words[i + 1] in aspect_words:
178
+ seen_lower.add(bigram.lower())
179
+ new_aspects.append(bigram)
180
+ if len(new_aspects) >= 3: # cap expansion
181
+ break
182
+ return aspects + new_aspects
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Main entry point
187
+ # ---------------------------------------------------------------------------
188
+
189
+ def decompose_task_aspects(task_intent: TaskIntent) -> DecompositionResult:
190
+ """Decompose *task_intent* into aspects with embedding vectors.
191
+
192
+ Algorithm (§3.2 Phase 1):
193
+ 1. Concatenate system_prompt + task_input
194
+ 2. Extract noun phrases as explicit aspects
195
+ 3. Expand with implicit aspects via dependency analysis
196
+ 4. Compute embedding for each aspect + full text
197
+ """
198
+ system = task_intent.system_prompt or ""
199
+ task = task_intent.task_input or ""
200
+ full_text = f"{system} {task}".strip()
201
+
202
+ if not full_text:
203
+ return DecompositionResult()
204
+
205
+ # Step 1-2: extract explicit aspects from noun phrases
206
+ explicit = _extract_noun_phrases(full_text)
207
+
208
+ # Step 3: implicit expansion
209
+ expanded = _expand_aspects_implicit(explicit, full_text)
210
+ if not expanded:
211
+ expanded = [full_text[:200]] # absolute fallback
212
+
213
+ # Step 4: compute embeddings
214
+ use_ml = _try_load_sentence_transformer()
215
+
216
+ if use_ml and _EMBED_MODEL is not None:
217
+ model = _EMBED_MODEL
218
+ texts_to_embed = expanded + [full_text]
219
+ embeddings = model.encode(texts_to_embed, show_progress_bar=False).tolist() # type: ignore[attr-defined]
220
+ aspect_embs = embeddings[:-1]
221
+ full_emb = embeddings[-1]
222
+ else:
223
+ # Fallback: bag-of-words vectors
224
+ all_tokens = _tokenize(full_text)
225
+ for a in expanded:
226
+ all_tokens.extend(_tokenize(a))
227
+ vocab = _build_vocab(all_tokens)
228
+ aspect_embs = [_bag_vector(_tokenize(a), vocab, _EMBED_DIM) for a in expanded]
229
+ full_emb = _bag_vector(_tokenize(full_text), vocab, _EMBED_DIM)
230
+
231
+ return DecompositionResult(
232
+ aspects=expanded,
233
+ aspect_embeddings=aspect_embs,
234
+ full_embedding=full_emb,
235
+ used_ml_model=use_ml,
236
+ )