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,109 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Information flow monitor — Δfacts/Δtokens rolling measurement (§4.3)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class FlowSample:
12
+ """Single measurement point in the flow monitor."""
13
+
14
+ window_id: str
15
+ facts_produced: int
16
+ tokens_consumed: int
17
+ timestamp: float = 0.0
18
+
19
+
20
+ @dataclass
21
+ class FlowMetrics:
22
+ """Current information flow metrics."""
23
+
24
+ current_rate: float # facts per 1000 tokens
25
+ rolling_average: float # rolling average over last N windows
26
+ trend: float # positive = increasing, negative = decreasing
27
+ sample_count: int
28
+ is_alive: bool # True if flow > 0
29
+
30
+
31
+ class InformationFlowMonitor:
32
+ """Measures Δfacts/Δtokens rolling rate across windows (§4.3).
33
+
34
+ Tracks how much new information the LLM is producing per token.
35
+ When flow drops to zero, the model has stopped producing new facts.
36
+ """
37
+
38
+ def __init__(self, rolling_window: int = 5) -> None:
39
+ self._samples: list[FlowSample] = []
40
+ self._rolling_window = max(1, rolling_window)
41
+
42
+ def record(self, window_id: str, facts_produced: int, tokens_consumed: int, timestamp: float = 0.0) -> None:
43
+ """Record a new flow sample after a window completes."""
44
+ self._samples.append(FlowSample(
45
+ window_id=window_id,
46
+ facts_produced=facts_produced,
47
+ tokens_consumed=max(1, tokens_consumed),
48
+ timestamp=timestamp,
49
+ ))
50
+
51
+ def current_rate(self) -> float:
52
+ """Facts per 1000 tokens for the most recent window."""
53
+ if not self._samples:
54
+ return 0.0
55
+ s = self._samples[-1]
56
+ return (s.facts_produced / s.tokens_consumed) * 1000.0
57
+
58
+ def rolling_average(self) -> float:
59
+ """Rolling average rate over the last N windows."""
60
+ if not self._samples:
61
+ return 0.0
62
+ recent = self._samples[-self._rolling_window:]
63
+ total_facts = sum(s.facts_produced for s in recent)
64
+ total_tokens = sum(s.tokens_consumed for s in recent)
65
+ if total_tokens == 0:
66
+ return 0.0
67
+ return (total_facts / total_tokens) * 1000.0
68
+
69
+ def trend(self) -> float:
70
+ """Rate of change: positive = flow increasing, negative = decreasing.
71
+
72
+ Computed as linear slope over rolling window.
73
+ """
74
+ recent = self._samples[-self._rolling_window:]
75
+ if len(recent) < 2:
76
+ return 0.0
77
+
78
+ rates = [(s.facts_produced / max(1, s.tokens_consumed)) * 1000.0 for s in recent]
79
+ n = len(rates)
80
+ x_mean = (n - 1) / 2.0
81
+ y_mean = sum(rates) / n
82
+
83
+ num = sum((i - x_mean) * (r - y_mean) for i, r in enumerate(rates))
84
+ den = sum((i - x_mean) ** 2 for i in range(n))
85
+ if den == 0.0:
86
+ return 0.0
87
+ return num / den
88
+
89
+ def is_alive(self) -> bool:
90
+ """True if information flow is still positive."""
91
+ return self.current_rate() > 0.0
92
+
93
+ def metrics(self) -> FlowMetrics:
94
+ """Get current flow metrics snapshot."""
95
+ return FlowMetrics(
96
+ current_rate=self.current_rate(),
97
+ rolling_average=self.rolling_average(),
98
+ trend=self.trend(),
99
+ sample_count=len(self._samples),
100
+ is_alive=self.is_alive(),
101
+ )
102
+
103
+ @property
104
+ def sample_count(self) -> int:
105
+ return len(self._samples)
106
+
107
+ def reset(self) -> None:
108
+ """Clear all samples."""
109
+ self._samples.clear()
@@ -0,0 +1,419 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Gap analysis — L1/L2/L3 requirement extraction and fulfillment scoring (§3.5)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import logging
9
+ import re
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+
13
+ logger = logging.getLogger("crp.continuation.gap")
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from crp.extraction.types import Fact
18
+
19
+
20
+ @dataclass
21
+ class Requirement:
22
+ """A single task requirement at a specific analysis level."""
23
+
24
+ text: str
25
+ level: int # 1=structural, 2=semantic, 3=LLM-assisted
26
+ category: str = ""
27
+ weight: float = 1.0
28
+ fulfilled: bool = False
29
+ fulfillment_score: float = 0.0
30
+
31
+
32
+ @dataclass
33
+ class GapResult:
34
+ """Result of gap analysis between requirements and output facts."""
35
+
36
+ requirements: list[Requirement]
37
+ gap_score: float # 0.0 = all fulfilled, 1.0 = nothing fulfilled
38
+ fulfilled_count: int
39
+ total_count: int
40
+ unfulfilled: list[Requirement]
41
+ details: dict[str, object] = field(default_factory=dict)
42
+
43
+ @property
44
+ def is_complete(self) -> bool:
45
+ return self.gap_score <= 0.0
46
+
47
+
48
+ # ── L1: Structural requirement extraction ──────────────────────────
49
+
50
+ _STRUCTURAL_PATTERNS: list[tuple[str, str]] = [
51
+ (r"\b(\d+)\s+(?:\w+\s+){0,2}(?:items?|points?|steps?|sections?|parts?)\b", "enumerated_items"),
52
+ (r"\b(?:list|enumerate|outline)\b", "list_structure"),
53
+ (r"\b(?:compare|contrast|versus|vs\.?)\b", "comparison"),
54
+ (r"\b(?:table|matrix|grid)\b", "tabular"),
55
+ (r"\b(?:code|implement|function|class|module)\b", "code_output"),
56
+ (r"\b(?:explain|describe|elaborate)\b", "explanation"),
57
+ (r"\b(?:summarize|summary|brief|concise)\b", "summary"),
58
+ (r"\b(?:analyze|analysis|evaluate)\b", "analysis"),
59
+ (r"\b(?:example|demonstrate|illustrate)\b", "example"),
60
+ (r"\b(?:pros?\s+(?:and|&)\s+cons?|advantages?\s+(?:and|&)\s+disadvantages?)\b", "pro_con"),
61
+ ]
62
+
63
+
64
+ def _extract_l1_structural(task_text: str) -> list[Requirement]:
65
+ """L1: regex-based structural requirement extraction.
66
+
67
+ When the task requests N numbered sections/items AND those items are
68
+ individually listed (e.g. ``1. Foo — description\\n2. Bar — ...``),
69
+ each item becomes its own requirement so gap analysis can track
70
+ per-section fulfillment rather than a single coarse "N sections" req.
71
+ """
72
+ reqs: list[Requirement] = []
73
+ seen_categories: set[str] = set()
74
+
75
+ for pattern, category in _STRUCTURAL_PATTERNS:
76
+ match = re.search(pattern, task_text, re.IGNORECASE)
77
+ if match and category not in seen_categories:
78
+ seen_categories.add(category)
79
+
80
+ # If this is an enumerated-items match (e.g. "30 sections"),
81
+ # try to expand into per-item requirements by parsing the
82
+ # numbered list that typically follows in the task text.
83
+ if category == "enumerated_items":
84
+ expanded = _expand_enumerated_items(task_text, match)
85
+ if expanded:
86
+ reqs.extend(expanded)
87
+ continue # skip the coarse "N sections" requirement
88
+
89
+ reqs.append(Requirement(
90
+ text=match.group(0),
91
+ level=1,
92
+ category=category,
93
+ weight=1.0,
94
+ ))
95
+
96
+ return reqs
97
+
98
+
99
+ def _expand_enumerated_items(task_text: str, enum_match: re.Match) -> list[Requirement]:
100
+ """Expand an 'N sections/items/steps' match into per-item requirements.
101
+
102
+ Looks for a numbered list in the task text (``1. Foo\\n2. Bar\\n...``)
103
+ and creates one Requirement per item with the item's title/description.
104
+ Returns empty list if no numbered list is found, letting the caller
105
+ fall back to the coarse requirement.
106
+ """
107
+ # Parse numbered list items: "N. Title" or "N. Title — description"
108
+ items: list[tuple[int, str]] = []
109
+ for m in re.finditer(
110
+ r"(?:^|\n)\s*(\d{1,3})[.)]\s+(.+?)(?=\n\s*\d{1,3}[.)]\s|\n\n|\Z)",
111
+ task_text,
112
+ re.DOTALL,
113
+ ):
114
+ num = int(m.group(1))
115
+ # Take the first line as the item title (strip sub-lines)
116
+ title = m.group(2).split("\n")[0].strip()
117
+ if title:
118
+ items.append((num, title))
119
+
120
+ if len(items) < 3:
121
+ return [] # Not enough items to justify expansion
122
+
123
+ reqs: list[Requirement] = []
124
+ for num, title in items:
125
+ # Extract just the section name (before any " — " description)
126
+ section_name = title.split(" — ")[0].split(" - ")[0].strip()
127
+ reqs.append(Requirement(
128
+ text=f"Section {num}: {section_name}",
129
+ level=1,
130
+ category=f"section_{num}",
131
+ weight=1.0,
132
+ ))
133
+ return reqs
134
+
135
+
136
+ # ── L2: Semantic requirement extraction ────────────────────────────
137
+
138
+ _SEMANTIC_MARKERS: list[tuple[str, str, float]] = [
139
+ (r"\b(?:must|shall|required?|need)\b", "mandatory", 1.5),
140
+ (r"\b(?:should|recommend|prefer)\b", "recommended", 1.0),
141
+ (r"\b(?:may|optional|consider)\b", "optional", 0.5),
142
+ (r"\b(?:don't|do not|avoid|never)\b", "constraint", 1.2),
143
+ (r"\b(?:include|contain|cover|address)\b", "inclusion", 1.0),
144
+ (r"\b(?:first|then|finally|next|after)\b", "sequential", 0.8),
145
+ ]
146
+
147
+
148
+ def _extract_l2_semantic(task_text: str) -> list[Requirement]:
149
+ """L2: semantic marker extraction for intent decomposition."""
150
+ reqs: list[Requirement] = []
151
+ sentences = re.split(r"[.!?]\s+", task_text)
152
+
153
+ for sentence in sentences:
154
+ sentence = sentence.strip()
155
+ if not sentence:
156
+ continue
157
+ for pattern, category, weight in _SEMANTIC_MARKERS:
158
+ if re.search(pattern, sentence, re.IGNORECASE):
159
+ reqs.append(Requirement(
160
+ text=sentence,
161
+ level=2,
162
+ category=category,
163
+ weight=weight,
164
+ ))
165
+ break # one marker per sentence
166
+
167
+ return reqs
168
+
169
+
170
+ # ── Requirement extraction (public) ────────────────────────────────
171
+
172
+ _requirement_cache: dict[str, list[Requirement]] = {}
173
+
174
+
175
+ def extract_task_requirements(
176
+ task_intent: str,
177
+ l3_extractor: Callable[[str], list[Requirement]] | None = None,
178
+ ) -> list[Requirement]:
179
+ """Extract requirements at L1 (structural) and L2 (semantic) levels.
180
+
181
+ L3 (LLM-assisted) can be provided via *l3_extractor* callback (§5B.1).
182
+ Results are cached by content hash (singleton pattern per §3.5).
183
+ """
184
+ # V11 fix: Use deterministic MD5 hash instead of Python's hash()
185
+ # which varies across interpreter runs (PYTHONHASHSEED).
186
+ cache_key = hashlib.md5(task_intent.encode()).hexdigest()
187
+ if cache_key in _requirement_cache:
188
+ return _requirement_cache[cache_key]
189
+
190
+ l1 = _extract_l1_structural(task_intent)
191
+ l2 = _extract_l2_semantic(task_intent)
192
+
193
+ # Deduplicate: L1 takes priority for same category
194
+ l1_categories = {r.category for r in l1}
195
+ combined = l1 + [r for r in l2 if r.category not in l1_categories]
196
+
197
+ # L3: LLM-assisted extraction when provider available (§5B.1)
198
+ if l3_extractor is not None:
199
+ try:
200
+ l3 = l3_extractor(task_intent)
201
+ existing_texts = {r.text.lower() for r in combined}
202
+ for r in l3:
203
+ if r.text.lower() not in existing_texts:
204
+ combined.append(r)
205
+ existing_texts.add(r.text.lower())
206
+ except Exception: # noqa: BLE001
207
+ logger.warning("L3 requirement extraction failed", exc_info=True)
208
+
209
+ if not combined:
210
+ combined = [Requirement(
211
+ text=task_intent[:200],
212
+ level=1,
213
+ category="general",
214
+ weight=1.0,
215
+ )]
216
+
217
+ _requirement_cache[cache_key] = combined
218
+ return combined
219
+
220
+
221
+ def clear_requirement_cache() -> None:
222
+ """Clear the requirement cache (for testing)."""
223
+ _requirement_cache.clear()
224
+
225
+
226
+ # ── Adaptive requirement discovery (GAP H) ────────────────────────
227
+
228
+ def discover_adaptive_requirements(
229
+ existing_reqs: list[Requirement],
230
+ document_headings: list[str] | None = None,
231
+ ) -> list[Requirement]:
232
+ """Discover new requirements from document headings not yet covered.
233
+
234
+ As continuation windows produce new sections, the DocumentMap tracks
235
+ headings. This function creates L2 requirements for sections that
236
+ appeared in the output but were not anticipated by the original task
237
+ analysis, ensuring the gap score tracks actual document completeness.
238
+ """
239
+ if not document_headings:
240
+ return existing_reqs
241
+
242
+ existing_lower = {r.text.lower() for r in existing_reqs}
243
+ existing_sections = set()
244
+ for r in existing_reqs:
245
+ m = re.match(r"section\s+(\d+)", r.text, re.IGNORECASE)
246
+ if m:
247
+ existing_sections.add(m.group(1))
248
+
249
+ new_reqs: list[Requirement] = []
250
+ for heading in document_headings:
251
+ heading_stripped = heading.strip().lstrip("#").strip()
252
+ if not heading_stripped:
253
+ continue
254
+ # Extract section number if present (e.g. "2. Background")
255
+ m = re.match(r"(\d+)\.\s*(.*)", heading_stripped)
256
+ if m:
257
+ sec_num, sec_title = m.group(1), m.group(2).strip()
258
+ if sec_num in existing_sections:
259
+ continue
260
+ req_text = f"Section {sec_num}: {sec_title}" if sec_title else f"Section {sec_num}"
261
+ else:
262
+ req_text = f"Cover: {heading_stripped}"
263
+
264
+ if req_text.lower() in existing_lower:
265
+ continue
266
+
267
+ new_reqs.append(Requirement(
268
+ text=req_text,
269
+ level=2,
270
+ category="adaptive_discovery",
271
+ weight=0.8, # slightly lower weight than original requirements
272
+ fulfilled=True, # already fulfilled since they came from output
273
+ fulfillment_score=1.0,
274
+ ))
275
+ existing_lower.add(req_text.lower())
276
+
277
+ return existing_reqs + new_reqs
278
+
279
+
280
+ # ── Fulfillment scoring ───────────────────────────────────────────
281
+
282
+ def _cosine_similarity(a: list[float], b: list[float]) -> float:
283
+ """Compute cosine similarity between two vectors."""
284
+ if len(a) != len(b) or not a:
285
+ return 0.0
286
+ dot = sum(x * y for x, y in zip(a, b))
287
+ norm_a = sum(x * x for x in a) ** 0.5
288
+ norm_b = sum(x * x for x in b) ** 0.5
289
+ if norm_a == 0.0 or norm_b == 0.0:
290
+ return 0.0
291
+ return dot / (norm_a * norm_b)
292
+
293
+
294
+ def _text_overlap(req_text: str, fact_text: str) -> float:
295
+ """Word overlap ratio as fallback similarity.
296
+
297
+ For section-level requirements (``Section N: Title``), also checks
298
+ whether the fact references the section number or title keywords,
299
+ enabling per-section fulfillment tracking.
300
+ """
301
+ req_lower = req_text.lower()
302
+ fact_lower = fact_text.lower()
303
+
304
+ # Fast path: section-level requirement matching
305
+ sec_match = re.match(r"section\s+(\d+):\s*(.+)", req_lower)
306
+ if sec_match:
307
+ sec_num = sec_match.group(1)
308
+ sec_title = sec_match.group(2).strip()
309
+
310
+ # Check if fact references this section number in a heading
311
+ if re.search(rf"(?:^|\n)\s*#{{1,3}}\s*{sec_num}\.", fact_lower):
312
+ return 1.0
313
+
314
+ # Check if fact contains the section title keywords
315
+ title_words = {w for w in sec_title.split() if len(w) > 3}
316
+ if title_words:
317
+ fact_words = set(fact_lower.split())
318
+ title_overlap = len(title_words & fact_words) / len(title_words)
319
+ if title_overlap >= 0.5:
320
+ return max(0.7, title_overlap)
321
+
322
+ # General word overlap
323
+ req_words = set(req_lower.split())
324
+ fact_words = set(fact_lower.split())
325
+ if not req_words:
326
+ return 0.0
327
+ overlap = req_words & fact_words
328
+ return len(overlap) / len(req_words)
329
+
330
+
331
+ FULFILLMENT_THRESHOLD = 0.65
332
+
333
+
334
+ def gap_analysis(
335
+ task_intent: str,
336
+ output_facts: list[Fact],
337
+ requirements: list[Requirement] | None = None,
338
+ embedding_fn: Callable[[str], list[float]] | None = None,
339
+ document_headings: list[str] | None = None,
340
+ ) -> GapResult:
341
+ """Compute gap between task requirements and produced facts (§3.5).
342
+
343
+ For each requirement, find best-matching fact. Uses cosine similarity
344
+ when *embedding_fn* is provided (§5B.3), otherwise falls back to word
345
+ overlap. Threshold: 0.65.
346
+
347
+ When *document_headings* is provided (from the DocumentMap), section-
348
+ level requirements are also matched against actual headings produced,
349
+ enabling accurate per-section tracking.
350
+ """
351
+ if requirements is None:
352
+ requirements = extract_task_requirements(task_intent)
353
+
354
+ # GAP H: Adaptive requirement discovery from document headings
355
+ requirements = discover_adaptive_requirements(requirements, document_headings)
356
+
357
+ reqs = [Requirement(
358
+ text=r.text, level=r.level, category=r.category,
359
+ weight=r.weight, fulfilled=r.fulfilled,
360
+ fulfillment_score=r.fulfillment_score,
361
+ ) for r in requirements]
362
+
363
+ fact_texts = [f.text for f in output_facts]
364
+
365
+ # If document headings are available, add them as pseudo-facts
366
+ # so section-level requirements can be matched against actual headings
367
+ if document_headings:
368
+ fact_texts = fact_texts + document_headings
369
+
370
+ # Pre-compute embeddings if embedding_fn available (§5B.3)
371
+ req_embeddings: list[list[float] | None] = []
372
+ fact_embeddings: list[list[float] | None] = []
373
+ if embedding_fn is not None:
374
+ try:
375
+ req_embeddings = [embedding_fn(r.text) for r in reqs]
376
+ fact_embeddings = [embedding_fn(ft) for ft in fact_texts]
377
+ except Exception: # noqa: BLE001
378
+ logger.warning("Embedding computation failed, falling back to text overlap", exc_info=True)
379
+ req_embeddings = []
380
+ fact_embeddings = []
381
+
382
+ use_embeddings = bool(req_embeddings and fact_embeddings)
383
+
384
+ for i, req in enumerate(reqs):
385
+ best_score = 0.0
386
+ for j, ft in enumerate(fact_texts):
387
+ # Always compute text_overlap (has section-level fast paths)
388
+ text_score = _text_overlap(req.text, ft)
389
+ if use_embeddings:
390
+ re_emb = req_embeddings[i]
391
+ fe_emb = fact_embeddings[j]
392
+ if re_emb and fe_emb:
393
+ cos_score = _cosine_similarity(re_emb, fe_emb)
394
+ # Use max of both: text_overlap catches section-number matches,
395
+ # cosine catches semantic similarity with different vocabulary
396
+ score = max(text_score, cos_score)
397
+ else:
398
+ score = text_score
399
+ else:
400
+ score = text_score
401
+ best_score = max(best_score, score)
402
+
403
+ req.fulfillment_score = best_score
404
+ req.fulfilled = best_score >= FULFILLMENT_THRESHOLD
405
+
406
+ fulfilled = [r for r in reqs if r.fulfilled]
407
+ unfulfilled = [r for r in reqs if not r.fulfilled]
408
+
409
+ total_weight = sum(r.weight for r in reqs) or 1.0
410
+ fulfilled_weight = sum(r.weight for r in fulfilled)
411
+ gap_score = 1.0 - (fulfilled_weight / total_weight)
412
+
413
+ return GapResult(
414
+ requirements=reqs,
415
+ gap_score=max(0.0, min(1.0, gap_score)),
416
+ fulfilled_count=len(fulfilled),
417
+ total_count=len(reqs),
418
+ unfulfilled=unfulfilled,
419
+ )