rag-wright 0.1.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 (184) hide show
  1. rag_wright/__init__.py +13 -0
  2. rag_wright/api/__init__.py +33 -0
  3. rag_wright/api/config.py +59 -0
  4. rag_wright/api/discover.py +70 -0
  5. rag_wright/api/documents.py +39 -0
  6. rag_wright/api/ids.py +31 -0
  7. rag_wright/api/invoke.py +99 -0
  8. rag_wright/api/kg.py +61 -0
  9. rag_wright/api/mcp.py +94 -0
  10. rag_wright/api/usage.py +30 -0
  11. rag_wright/api/workspace.py +85 -0
  12. rag_wright/capabilities/__init__.py +8 -0
  13. rag_wright/capabilities/answer_generator.py +427 -0
  14. rag_wright/capabilities/ard.py +286 -0
  15. rag_wright/capabilities/assertion_extraction.py +79 -0
  16. rag_wright/capabilities/chunk_read.py +58 -0
  17. rag_wright/capabilities/chunk_write.py +163 -0
  18. rag_wright/capabilities/claim_extraction.py +153 -0
  19. rag_wright/capabilities/clause_exception_linking.py +117 -0
  20. rag_wright/capabilities/compliance_judgment.py +322 -0
  21. rag_wright/capabilities/compliance_store.py +87 -0
  22. rag_wright/capabilities/contract_kg_serve.py +156 -0
  23. rag_wright/capabilities/contract_kg_store.py +251 -0
  24. rag_wright/capabilities/dg_extraction.py +585 -0
  25. rag_wright/capabilities/disambiguation.py +163 -0
  26. rag_wright/capabilities/document_parse.py +87 -0
  27. rag_wright/capabilities/document_scope.py +49 -0
  28. rag_wright/capabilities/embedding.py +164 -0
  29. rag_wright/capabilities/embedding_profiles.py +43 -0
  30. rag_wright/capabilities/entity_resolution.py +154 -0
  31. rag_wright/capabilities/fusion.py +64 -0
  32. rag_wright/capabilities/graph_extraction.py +243 -0
  33. rag_wright/capabilities/graph_query.py +73 -0
  34. rag_wright/capabilities/graph_storage.py +111 -0
  35. rag_wright/capabilities/highlight_serve.py +142 -0
  36. rag_wright/capabilities/hybrid_search.py +65 -0
  37. rag_wright/capabilities/invoke.py +31 -0
  38. rag_wright/capabilities/jev_decision.py +38 -0
  39. rag_wright/capabilities/manifests.py +872 -0
  40. rag_wright/capabilities/okf_navigate.py +456 -0
  41. rag_wright/capabilities/parsing.py +286 -0
  42. rag_wright/capabilities/property_boosted_retrieval.py +125 -0
  43. rag_wright/capabilities/query_function_classifier.py +94 -0
  44. rag_wright/capabilities/query_understanding.py +109 -0
  45. rag_wright/capabilities/registry.py +262 -0
  46. rag_wright/capabilities/remote_encoders.py +94 -0
  47. rag_wright/capabilities/requirement_extraction.py +247 -0
  48. rag_wright/capabilities/reranking.py +123 -0
  49. rag_wright/capabilities/retrieval_core.py +126 -0
  50. rag_wright/capabilities/rlm_chunking.py +808 -0
  51. rag_wright/capabilities/rlm_synthesis.py +316 -0
  52. rag_wright/capabilities/scan_quality.py +136 -0
  53. rag_wright/capabilities/span_relevance_judgment.py +191 -0
  54. rag_wright/capabilities/vision_to_text.py +85 -0
  55. rag_wright/capabilities/vlm_ocr.py +85 -0
  56. rag_wright/contracts/__init__.py +6 -0
  57. rag_wright/contracts/chunk.py +79 -0
  58. rag_wright/contracts/compliance.py +303 -0
  59. rag_wright/contracts/contract_meta.py +27 -0
  60. rag_wright/contracts/extraction.py +130 -0
  61. rag_wright/contracts/function.py +167 -0
  62. rag_wright/contracts/function_routing.py +91 -0
  63. rag_wright/contracts/highlight.py +74 -0
  64. rag_wright/contracts/identifiers.py +153 -0
  65. rag_wright/contracts/jurisdiction.py +96 -0
  66. rag_wright/contracts/ontology.py +142 -0
  67. rag_wright/contracts/property.py +201 -0
  68. rag_wright/contracts/provenance.py +78 -0
  69. rag_wright/contracts/query_intent.py +53 -0
  70. rag_wright/contracts/span.py +76 -0
  71. rag_wright/contracts/value_match.py +84 -0
  72. rag_wright/corpus/__init__.py +0 -0
  73. rag_wright/corpus/canonicalize.py +116 -0
  74. rag_wright/corpus/cuad.py +153 -0
  75. rag_wright/corpus/cuad_ingestion.py +72 -0
  76. rag_wright/corpus/document_parser.py +299 -0
  77. rag_wright/corpus/edgar.py +231 -0
  78. rag_wright/corpus/gcs_ingestion.py +120 -0
  79. rag_wright/corpus/http.py +110 -0
  80. rag_wright/corpus/selection.py +152 -0
  81. rag_wright/mcp/__init__.py +11 -0
  82. rag_wright/mcp/compliance_server.py +299 -0
  83. rag_wright/mcp/intra_document_qa_server.py +170 -0
  84. rag_wright/mcp/relational_qa_server.py +171 -0
  85. rag_wright/mcp/session_store.py +64 -0
  86. rag_wright/mcp/typed_property_retrieval_server.py +191 -0
  87. rag_wright/models/__init__.py +8 -0
  88. rag_wright/models/profiles.py +331 -0
  89. rag_wright/models/seam.py +497 -0
  90. rag_wright/models/tag_structured.py +285 -0
  91. rag_wright/models/tracing.py +179 -0
  92. rag_wright/models/usage.py +102 -0
  93. rag_wright/okf/__init__.py +11 -0
  94. rag_wright/okf/compile.py +292 -0
  95. rag_wright/okf/document.py +47 -0
  96. rag_wright/okf/enrich.py +176 -0
  97. rag_wright/okf/links.py +190 -0
  98. rag_wright/okf/lint.py +105 -0
  99. rag_wright/ontology/__init__.py +6 -0
  100. rag_wright/ontology/_generated_template_meta.py +60 -0
  101. rag_wright/ontology/_generated_vocab.py +52 -0
  102. rag_wright/ontology/clause_template.py +964 -0
  103. rag_wright/ontology/codegen.py +84 -0
  104. rag_wright/ontology/compliance_bridge.ttl +186 -0
  105. rag_wright/ontology/contract_bridge.ttl +2685 -0
  106. rag_wright/ontology/contract_taxonomy.py +24 -0
  107. rag_wright/ontology/derive.py +58 -0
  108. rag_wright/ontology/loader.py +435 -0
  109. rag_wright/ontology/packs/ftc_16cfr255.ttl +29 -0
  110. rag_wright/ontology/registry.py +87 -0
  111. rag_wright/ontology/template_introspect.py +100 -0
  112. rag_wright/py.typed +0 -0
  113. rag_wright/reference/__init__.py +2 -0
  114. rag_wright/reference/compliance.py +41 -0
  115. rag_wright/reference/contract_seam.py +123 -0
  116. rag_wright/skills/__init__.py +7 -0
  117. rag_wright/skills/claim_extraction/SKILL.md +47 -0
  118. rag_wright/skills/claim_extraction/__init__.py +1 -0
  119. rag_wright/skills/claim_extraction/template.py +50 -0
  120. rag_wright/skills/compliance_judgment/SKILL.md +59 -0
  121. rag_wright/skills/corpus_ingest/SKILL.md +106 -0
  122. rag_wright/skills/extraction_semantic_judge/SKILL.md +51 -0
  123. rag_wright/skills/extraction_semantic_judge/__init__.py +1 -0
  124. rag_wright/skills/generation/SKILL.md +64 -0
  125. rag_wright/skills/generation/__init__.py +1 -0
  126. rag_wright/skills/generic_compliance_judgment/SKILL.md +58 -0
  127. rag_wright/skills/okf_navigate/SKILL.md +137 -0
  128. rag_wright/skills/requirement_extraction/SKILL.md +47 -0
  129. rag_wright/skills/requirement_extraction/__init__.py +1 -0
  130. rag_wright/skills/requirement_extraction/template.py +50 -0
  131. rag_wright/skills/rlm/SKILL.md +186 -0
  132. rag_wright/skills/rlm/__init__.py +31 -0
  133. rag_wright/skills/rlm/agent.py +292 -0
  134. rag_wright/skills/span_relevance_judgment/SKILL.md +67 -0
  135. rag_wright/skills/vision_to_text/SKILL.md +36 -0
  136. rag_wright/skills/vision_to_text/__init__.py +1 -0
  137. rag_wright/spans/__init__.py +1 -0
  138. rag_wright/spans/boundary.py +78 -0
  139. rag_wright/spans/clause_function_classifier.py +490 -0
  140. rag_wright/spans/clause_kg_extractor.py +337 -0
  141. rag_wright/spans/cuad_labels.py +81 -0
  142. rag_wright/spans/dim_classifier.py +158 -0
  143. rag_wright/spans/dim_fleet.json +411 -0
  144. rag_wright/spans/function_classifier.py +77 -0
  145. rag_wright/spans/function_families.py +62 -0
  146. rag_wright/spans/hybrid_classifier.py +103 -0
  147. rag_wright/spans/legalbert_classifier.py +83 -0
  148. rag_wright/spans/model_capabilities.py +107 -0
  149. rag_wright/spans/new_function_labels.py +111 -0
  150. rag_wright/spans/page_map.py +68 -0
  151. rag_wright/spans/property_extractor.py +365 -0
  152. rag_wright/spans/property_grounding.py +182 -0
  153. rag_wright/spans/reclassify.py +77 -0
  154. rag_wright/spans/scarce_function_labels.py +105 -0
  155. rag_wright/spans/segment.py +341 -0
  156. rag_wright/spans/semantic_judge.py +197 -0
  157. rag_wright/spans/symbolic_validation.py +131 -0
  158. rag_wright/spans/tag_clause_extractor.py +182 -0
  159. rag_wright/store/__init__.py +6 -0
  160. rag_wright/store/arcadedb.py +1135 -0
  161. rag_wright/store/chunk_text.py +66 -0
  162. rag_wright/store/seam.py +213 -0
  163. rag_wright/subgraphs/__init__.py +0 -0
  164. rag_wright/subgraphs/async_ingestion.py +204 -0
  165. rag_wright/subgraphs/compliance_check.py +1042 -0
  166. rag_wright/subgraphs/compliance_ingestion.py +306 -0
  167. rag_wright/subgraphs/contract_ingestion_pipeline.py +999 -0
  168. rag_wright/subgraphs/graph_extraction.py +102 -0
  169. rag_wright/subgraphs/intra_document_qa.py +328 -0
  170. rag_wright/subgraphs/observability.py +140 -0
  171. rag_wright/subgraphs/query_constraint_extraction.py +73 -0
  172. rag_wright/subgraphs/relational_qa.py +165 -0
  173. rag_wright/subgraphs/requirement_extraction.py +137 -0
  174. rag_wright/subgraphs/scaffold.py +65 -0
  175. rag_wright/subgraphs/semantic_chunking.py +183 -0
  176. rag_wright/subgraphs/typed_clause_extraction.py +172 -0
  177. rag_wright/subgraphs/typed_property_retrieval.py +278 -0
  178. rag_wright/util/__init__.py +1 -0
  179. rag_wright/util/concurrent.py +153 -0
  180. rag_wright/util/spacy_model.py +45 -0
  181. rag_wright-0.1.0.dist-info/METADATA +168 -0
  182. rag_wright-0.1.0.dist-info/RECORD +184 -0
  183. rag_wright-0.1.0.dist-info/WHEEL +4 -0
  184. rag_wright-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,163 @@
1
+ """Entity disambiguation and canonicalization (FR-C.7, T23b): normalize, reject, cluster.
2
+
3
+ The stage between recognition (T23) and closed-world linking (T24). It takes the raw `EntityMention`
4
+ stream from graph extraction and turns it into canonical mention clusters, each a proposal a human
5
+ verifies before T24 links it to an EDGAR CIK. Three deterministic stages (ADR-0004): normalize surface
6
+ variants to one key, reject non-entities, cluster survivors by key. Two things this capability adds on
7
+ top of the T10 rules it reuses (`corpus.canonicalize`): it carries `chunk_id` provenance and confidence
8
+ onto each cluster, and it applies the conservative-merge bias (ADR-0004 C3/C4) by FLAGGING ambiguous
9
+ near-duplicates (parent/subsidiary or shared-token pairs) for a human decision rather than merging them
10
+ — a false merge is a silent, invisible error, worse than a false split the human can see.
11
+
12
+ Full coreference (pronouns, definite descriptions like "the Company" bound to a party) is deferred
13
+ behind the `CoreferenceResolver` seam, the same discipline as OpenIE at T5: a stable interface
14
+ additional resolvers bind later, with nothing here reopened.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Sequence
20
+ from typing import Protocol, runtime_checkable
21
+
22
+ from pydantic import BaseModel
23
+
24
+ from rag_wright.capabilities.registry import CapabilityRegistry
25
+ from rag_wright.contracts.extraction import ExtractionResult
26
+ from rag_wright.contracts.provenance import ConfidenceTag
27
+ from rag_wright.corpus.canonicalize import is_entity, normalize_entity_name
28
+
29
+ # Confidence weakness rank: a cluster carries the WEAKEST tag among its mentions (a cluster is only as
30
+ # trustworthy as its least-certain member).
31
+ _CONFIDENCE_RANK = {ConfidenceTag.EXTRACTED: 0, ConfidenceTag.INFERRED: 1, ConfidenceTag.AMBIGUOUS: 2}
32
+ _RANK_TO_CONFIDENCE = {rank: tag for tag, rank in _CONFIDENCE_RANK.items()}
33
+
34
+ # Stopwords dropped only for the near-duplicate token comparison (not for the clustering key), so
35
+ # "Bank of America" vs "Bank of England" are not flagged on the shared "bank"/"of".
36
+ _NEAR_DUP_STOPWORDS = frozenset({"of", "the", "and", "for", "a", "an"})
37
+
38
+
39
+ class MentionCluster(BaseModel):
40
+ """One candidate real-world entity: a human-verifiable proposal, never an auto-committed merge.
41
+
42
+ Carries `chunk_id` provenance and confidence (FR-S.4). `ambiguous_with` lists the keys of near-
43
+ duplicate clusters flagged for a human to decide off the contract language (ADR-0004 C4); it is the
44
+ conservative-merge bias made visible — these are NOT merged automatically.
45
+ """
46
+
47
+ key: str # the normalized clustering key
48
+ representative: str # the longest surface form, for display + registry matching
49
+ variants: list[str]
50
+ entity_type: str # opaque domain entity type (DD-5); carried through from the mention
51
+ confidence: ConfidenceTag # weakest over the clustered mentions
52
+ chunk_ids: list[str] # provenance: the chunks the mentions came from
53
+ ambiguous_with: list[str] = [] # keys of flagged near-duplicate clusters (human decides)
54
+
55
+
56
+ class DisambiguationResult(BaseModel):
57
+ """The capability's output: canonical clusters (proposals) plus the rejected surface forms (audit)."""
58
+
59
+ clusters: list[MentionCluster]
60
+ rejected: list[str]
61
+
62
+
63
+ @runtime_checkable
64
+ class CoreferenceResolver(Protocol):
65
+ """The deferred full-coreference seam (ADR-0004): additional resolvers (pronoun / definite-
66
+ description coreference) bind this later and rewrite the cluster set; nothing here changes. A
67
+ resolver returns the (possibly merged) clusters. Default: no resolvers — clustering is by surface
68
+ form only."""
69
+
70
+ name: str
71
+
72
+ def resolve(self, clusters: list[MentionCluster]) -> list[MentionCluster]: ...
73
+
74
+
75
+ def _weakest(confidences: list[ConfidenceTag]) -> ConfidenceTag:
76
+ return _RANK_TO_CONFIDENCE[max(_CONFIDENCE_RANK[c] for c in confidences)]
77
+
78
+
79
+ def _near_duplicate(key_a: str, key_b: str) -> bool:
80
+ """Two cluster keys are ambiguous near-duplicates (flag, do not merge): one key's significant
81
+ tokens are a proper subset of the other's (parent/subsidiary, extra-qualifier), or they share a
82
+ first token and overlap substantially (shared-token pair). Biased to over-flag (human decides)."""
83
+ tokens_a = [t for t in key_a.split() if t not in _NEAR_DUP_STOPWORDS]
84
+ tokens_b = [t for t in key_b.split() if t not in _NEAR_DUP_STOPWORDS]
85
+ set_a, set_b = set(tokens_a), set(tokens_b)
86
+ if not set_a or not set_b or set_a == set_b:
87
+ return False
88
+ if set_a < set_b or set_b < set_a: # proper token subset
89
+ return True
90
+ if tokens_a[0] == tokens_b[0]: # shared first token + substantial overlap
91
+ return len(set_a & set_b) / len(set_a | set_b) >= 0.5
92
+ return False
93
+
94
+
95
+ def disambiguate(
96
+ results: Sequence[ExtractionResult],
97
+ *,
98
+ coreference_resolvers: Sequence[CoreferenceResolver] = (),
99
+ ) -> DisambiguationResult:
100
+ """Normalize, reject, and cluster the extracted entity mentions into human-verifiable proposals.
101
+
102
+ Mentions are collected across the extraction results (their `chunk_id` is provenance), non-entities
103
+ are rejected (never reach T24), survivors are clustered by (normalized key, type), each cluster
104
+ carries its provenance and weakest confidence, deferred coreference resolvers (if any) rewrite the
105
+ clusters, and ambiguous near-duplicates are flagged for human decision (never merged).
106
+ """
107
+ groups: dict[tuple[str, str], dict] = {} # (normalized key, entity_type) -> cluster accumulator
108
+ rejected: list[str] = []
109
+ for result in results:
110
+ chunk_id = result.chunk_id.value
111
+ for mention in result.entity_mentions:
112
+ if not is_entity(mention.text):
113
+ rejected.append(mention.text)
114
+ continue
115
+ key = normalize_entity_name(mention.text)
116
+ group = groups.setdefault(
117
+ (key, mention.entity_type),
118
+ {"variants": [], "confidences": [], "chunk_ids": set()},
119
+ )
120
+ if mention.text not in group["variants"]:
121
+ group["variants"].append(mention.text)
122
+ group["confidences"].append(mention.confidence)
123
+ group["chunk_ids"].add(chunk_id)
124
+
125
+ clusters = [
126
+ MentionCluster(
127
+ key=key,
128
+ representative=max(group["variants"], key=len),
129
+ variants=sorted(group["variants"]),
130
+ entity_type=entity_type,
131
+ confidence=_weakest(group["confidences"]),
132
+ chunk_ids=sorted(group["chunk_ids"]),
133
+ )
134
+ for (key, entity_type), group in groups.items()
135
+ ]
136
+
137
+ for resolver in coreference_resolvers: # deferred seam (default: none)
138
+ clusters = resolver.resolve(clusters)
139
+
140
+ _flag_near_duplicates(clusters)
141
+ clusters.sort(key=lambda c: c.key)
142
+ return DisambiguationResult(clusters=clusters, rejected=rejected)
143
+
144
+
145
+ def _flag_near_duplicates(clusters: list[MentionCluster]) -> None:
146
+ """Set each cluster's `ambiguous_with` (symmetric) for same-type near-duplicate pairs (ADR-0004 C4)."""
147
+ for i, a in enumerate(clusters):
148
+ for b in clusters[i + 1 :]:
149
+ if a.entity_type == b.entity_type and _near_duplicate(a.key, b.key):
150
+ a.ambiguous_with.append(b.key)
151
+ b.ambiguous_with.append(a.key)
152
+ for cluster in clusters:
153
+ cluster.ambiguous_with.sort()
154
+
155
+
156
+ def register_entity_disambiguation(registry: CapabilityRegistry) -> None:
157
+ """Register under FR-C.7 (`entity_disambiguation`, an in-process `function`)."""
158
+ registry.register(
159
+ "entity_disambiguation",
160
+ contract=DisambiguationResult,
161
+ kind="function",
162
+ display_name="Entity disambiguation (normalize / reject / cluster)",
163
+ )
@@ -0,0 +1,87 @@
1
+ """Generic document-parse surface (EP-API-6b): the corpus-seam `SourceDocument` contract + the docling-parse
2
+ helpers that build one from raw bytes.
3
+
4
+ DOMAIN-FREE on purpose. It uses only the generic docling parser (`capabilities.parsing`) and the generic text
5
+ extraction (`corpus.document_parser`) -- nothing contract/clause/edgar -- so the engine's public parse API
6
+ (`api.parse_document`) depends on THIS, not on the contract ingestion pipeline (which would drag the whole
7
+ contract reference pack into any caller just to parse a PDF). A byte-source corpus adapter for ANY domain builds
8
+ a structure-bearing `SourceDocument` the same way. (Relocated out of `subgraphs/contract_ingestion_pipeline`,
9
+ which now re-exports these for its existing importers.)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from typing import Any, Optional
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from rag_wright.capabilities.parsing import ParsedDocument
19
+
20
+ _INGEST_PARSE_DEADLINE_S = 600.0 # per-document parse ceiling (a degraded multi-page doc escalated to the VLM)
21
+
22
+
23
+ class SourceDocument(BaseModel):
24
+ """One document to ingest: its canonical `source_doc_id` (HYG-1), its already-parsed text, and optional
25
+ per-corpus metadata (e.g. CUAD annotated parties, ACORD pre-segmented spans) the stages may consult."""
26
+
27
+ source_doc_id: str
28
+ text: str
29
+ metadata: dict = {}
30
+ # CHUNK-7 (ADR-0058, issue 0004): the REAL docling parse (structure preserved), set by a BYTE-source adapter
31
+ # (or `parsed_source_document`). When present, the chunk stage uses it so the structural pass fires on the
32
+ # document's actual headings; when None (genuinely text-only input) the chunker falls back to a text parse.
33
+ parsed: Optional[ParsedDocument] = None
34
+ # 0009-WIRE2: pages the tiered OCR could not read even after VLM escalation (a degraded scan) -- surfaced as an
35
+ # `ocr` PARTIAL in the IngestionReport, never silently ingested as gibberish.
36
+ ocr_unreadable_pages: list[int] = []
37
+
38
+
39
+ def parsed_source_document(
40
+ source_doc_id: str, name: str, data: bytes, *, cache_dir: Any, metadata: Optional[dict] = None
41
+ ) -> SourceDocument:
42
+ """Build a STRUCTURE-BEARING `SourceDocument` from raw document BYTES (PDF/DOCX/HTML/MD): docling-parse ONCE
43
+ (content-hash gated + cached), carry the `DoclingDocument` on `.parsed` (so the chunker's structural pass
44
+ fires on real headings), and set `.text` to the flattened text (for the text-consuming stages). This is how a
45
+ byte-source corpus adapter -- or the product (RuleWright), which hand-builds its ingest -- feeds a real
46
+ document to the engine; a plain-text `SourceDocument` (no `.parsed`) still uses the text fallback."""
47
+ import hashlib
48
+ import json
49
+ from pathlib import Path
50
+
51
+ from rag_wright.capabilities.parsing import TieredOCRParser, load_document
52
+ from rag_wright.corpus.document_parser import document_to_text, parse_document_bytes
53
+
54
+ cache_dir = Path(cache_dir)
55
+ cache_dir.mkdir(parents=True, exist_ok=True)
56
+ content_hash = hashlib.sha256(data).hexdigest()
57
+ manifest_path = cache_dir / f"{source_doc_id}.{content_hash[:16]}.json"
58
+ ocr_sidecar = cache_dir / f"{source_doc_id}.{content_hash[:16]}.ocr.json" # the OCR verdict, cached alongside
59
+ parsed = ParsedDocument(
60
+ source_doc_id=source_doc_id, content_hash=content_hash, manifest_path=str(manifest_path))
61
+ if manifest_path.exists(): # content-hash gate: parse once (restore the OCR verdict from the sidecar)
62
+ document = load_document(parsed)
63
+ unreadable = json.loads(ocr_sidecar.read_text()) if ocr_sidecar.exists() else []
64
+ else: # 0009-WIRE2: tiered OCR -- capture which pages stayed unreadable even after VLM, so they surface PARTIAL
65
+ tiered = TieredOCRParser()
66
+ document = parse_document_bytes(name, data, parser=tiered)
67
+ document.save_as_json(manifest_path)
68
+ unreadable = list(tiered.report.unreadable_pages)
69
+ ocr_sidecar.write_text(json.dumps(unreadable))
70
+ return SourceDocument(
71
+ source_doc_id=source_doc_id, text=document_to_text(document), parsed=parsed, metadata=metadata or {},
72
+ ocr_unreadable_pages=unreadable)
73
+
74
+
75
+ async def aparsed_source_document(
76
+ source_doc_id: str, name: str, data: bytes, *, cache_dir: Any, metadata: Optional[dict] = None,
77
+ deadline_s: float = _INGEST_PARSE_DEADLINE_S,
78
+ ) -> SourceDocument:
79
+ """The ASYNC, deadline-bounded twin of `parsed_source_document` (ADR-0057) -- STABLE PUBLIC API. Runs the sync
80
+ build (docling parse + the tiered OCR/VLM escalation, the slowest call in the pipeline) OFF the event loop
81
+ (`to_thread`) under an `asyncio.timeout`, so a hand-built async ingest can parse a document into the
82
+ structure-bearing `SourceDocument` the chunker needs WITHOUT reimplementing the wrapper (or blocking the loop).
83
+ Same caveat as every `to_thread` bound: the deadline unblocks the CALLER; the docling worker thread finishes in
84
+ the background (true cancellation would route the vision call through the async model seam)."""
85
+ async with asyncio.timeout(deadline_s):
86
+ return await asyncio.to_thread(
87
+ parsed_source_document, source_doc_id, name, data, cache_dir=cache_dir, metadata=metadata)
@@ -0,0 +1,49 @@
1
+ """Issue 0031: a workspace document scope for corpus-wide retrieval.
2
+
3
+ A `documents` list narrows a corpus-wide query (span retrieval, graph traversal) to a subset of the store's
4
+ source documents, applied IN THE STORE so out-of-scope content is never pooled, embedded against, judged, or
5
+ cited (the same argument issue 0007 settled for compliance `sources`). This module holds the shared
6
+ validation both consumers use: an unknown document id RAISES rather than silently matching nothing (a filter
7
+ that quietly matches nothing is indistinguishable from an empty workspace -- the exact failure this scope
8
+ exists to prevent).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Optional
14
+
15
+
16
+ class UnknownDocumentError(ValueError):
17
+ """A `documents` scope named a document id that was never ingested into the store (issue 0031). Mirrors
18
+ compliance's `UnknownComplianceSourceError` (issue 0007): naming a document that does not exist is a
19
+ caller error, surfaced explicitly rather than silently returning empty. Carries `.unknown` (the offending
20
+ ids) and `.present` (the FULL known-document set) as structured attributes; the message names every
21
+ unknown id but samples `.present`, which can run to thousands of documents in a real store."""
22
+
23
+ _PRESENT_SAMPLE = 20
24
+
25
+ def __init__(self, unknown: list[str], present: list[str]) -> None:
26
+ self.unknown = unknown
27
+ self.present = present # full set, for programmatic inspection
28
+ if len(present) > self._PRESENT_SAMPLE:
29
+ shown = f"{present[:self._PRESENT_SAMPLE]} ... (+{len(present) - self._PRESENT_SAMPLE} more)"
30
+ else:
31
+ shown = str(present)
32
+ super().__init__(f"unknown document id(s): {unknown}; present in store: {shown}")
33
+
34
+
35
+ def validate_documents(store: Any, documents: Optional[list[str]]) -> None:
36
+ """Validate a `documents` scope against `store.known_document_ids()` BEFORE any retrieval spends (mirrors
37
+ issue 0007's `_validate_sources`). `None` (whole store) is not validated. `[]` (scope-to-nothing) is a
38
+ valid empty scope, not an error -- the retrieval surfaces short-circuit it. A non-empty list with any id
39
+ absent from the store raises `UnknownDocumentError`. A store without `known_document_ids` (a minimal fake)
40
+ is treated as un-validatable and passes through."""
41
+ if not documents: # None or [] -> nothing to validate
42
+ return
43
+ known = getattr(store, "known_document_ids", None)
44
+ if known is None:
45
+ return
46
+ present = known()
47
+ unknown = sorted(set(documents) - present)
48
+ if unknown:
49
+ raise UnknownDocumentError(unknown, sorted(present))
@@ -0,0 +1,164 @@
1
+ """Embedding capability (FR-C.2, FR-I.3): dense over the summary, native sparse over the full text.
2
+
3
+ From one BGE-M3 model, produce a dense vector over the chunk summary and a native sparse vector over
4
+ the full chunk text. The dense-over-summary + sparse-over-full-text split is the summary-miss
5
+ mitigation (a detail dropped from the summary is still recoverable through the sparse leg). Output
6
+ shapes match the T3 chunk-record contract (dense length `BGE_M3_DENSE_DIM`; sparse `dict[int, float]`,
7
+ BGE-M3's string token-id keys converted to ints).
8
+
9
+ FR-I.6 decoupling: embedding is a GPU-calling capability, so it is built to be called concurrently
10
+ and non-blocking. `embed_chunks` is async; each encode goes through `asyncio.to_thread` (a poolable
11
+ inference boundary, not a hardcoded synchronous call), and a semaphore applies backpressure so bulk
12
+ mode can saturate the GPU without unbounded in-flight work. Grounded on `FlagEmbedding.BGEM3FlagModel`
13
+ (the public BGE-M3 class; its inference engine is `M3Embedder`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import math
20
+ import threading
21
+ from typing import Protocol, runtime_checkable
22
+
23
+ from pydantic import BaseModel, field_validator
24
+
25
+ from rag_wright.capabilities.rlm_chunking import Chunk
26
+ from rag_wright.contracts.chunk import BGE_M3_DENSE_DIM
27
+
28
+ DEFAULT_MAX_CONCURRENCY = 4 # in-flight encode budget (backpressure); tune per GPU at deploy
29
+
30
+
31
+ @runtime_checkable
32
+ class Embedder(Protocol):
33
+ """The BGE-M3 inference seam: dense and native-sparse encoders."""
34
+
35
+ def encode_dense(self, text: str) -> list[float]: ...
36
+ def encode_sparse(self, text: str) -> dict[int, float]: ...
37
+
38
+
39
+ class ChunkEmbedding(BaseModel):
40
+ """The embedding capability's output: one chunk's dense and sparse vectors."""
41
+
42
+ model_config = {"frozen": True}
43
+
44
+ chunk_id: str
45
+ dense_vector: list[float] # over the summary; length BGE_M3_DENSE_DIM
46
+ sparse_vector: dict[int, float] # over the full text; token-id -> non-negative weight
47
+
48
+ @field_validator("dense_vector")
49
+ @classmethod
50
+ def _check_dense(cls, v: list[float]) -> list[float]:
51
+ if len(v) != BGE_M3_DENSE_DIM:
52
+ raise ValueError(f"dense_vector must have length {BGE_M3_DENSE_DIM}, got {len(v)}")
53
+ if not all(math.isfinite(x) for x in v):
54
+ raise ValueError("dense_vector must contain only finite values")
55
+ return v
56
+
57
+ @field_validator("sparse_vector")
58
+ @classmethod
59
+ def _check_sparse(cls, v: dict[int, float]) -> dict[int, float]:
60
+ if any(weight < 0 for weight in v.values()):
61
+ raise ValueError("sparse_vector weights must be non-negative")
62
+ return v
63
+
64
+
65
+ def _resolve_device(device: str | None) -> str:
66
+ """The device to run BGE-M3 on: explicit arg, else `EMBED_DEVICE`, else auto (Metal `mps` when available,
67
+ else `cpu`). Auto-MPS offloads embedding from the CPU (freeing it for LegalBERT + extraction during ingest)
68
+ and is ~2x faster; the vectors are bit-for-bit equivalent to CPU (verified cosine 1.0), so it is a pure
69
+ speed choice, not a semantic one."""
70
+ import os
71
+
72
+ chosen = device or os.environ.get("EMBED_DEVICE")
73
+ if chosen:
74
+ return chosen
75
+ try:
76
+ import torch
77
+
78
+ if torch.backends.mps.is_available():
79
+ return "mps"
80
+ if torch.cuda.is_available(): # a GPU box (e.g. the bulk-ingestion VM) -> use the GPU
81
+ return "cuda"
82
+ except Exception: # noqa: BLE001 - torch/device probing must never break model construction
83
+ pass
84
+ return "cpu"
85
+
86
+
87
+ class BGEM3Embedder:
88
+ """The real embedder: BGE-M3 via `FlagEmbedding.BGEM3FlagModel` (model loaded lazily).
89
+
90
+ THREAD-SAFE (engine issue 0016): FlagEmbedding mutates the model IN PLACE on every `encode`
91
+ (`self.model.float()` / `.to(device)` / `.eval()`), and those C++ conversions release the GIL, so two threads
92
+ driving one shared model swap the same parameter-tensor storage under each other and SEGFAULT the interpreter
93
+ (not a catchable exception). The engine SHARES one `Embedder` across concurrent-document ingestion
94
+ (`run_job(max_concurrency=...)` -> per-document `encode_batch`) and across `embed_chunks`' per-chunk thread
95
+ fan-out. So every model call is serialized by an instance lock. The lock guards ONLY `self._model.encode`
96
+ (the numpy results are per-call, thread-local); the serialized section is the CPU/GPU encode, which is a small
97
+ slice of ingest time -- the concurrency worth overlapping is the network-bound extraction, not the encode."""
98
+
99
+ def __init__(self, model_name: str = "BAAI/bge-m3", *, use_fp16: bool = False,
100
+ device: str | None = None, batch_size: int = 64, model: object | None = None) -> None:
101
+ self._batch_size = batch_size # cross-item independent, so batching never changes a vector, only speed
102
+ self._lock = threading.Lock() # issue 0016: serialize the in-place-mutating BGE-M3 encode across threads
103
+ if model is not None: # injected (hermetic tests) -- avoids loading the real model
104
+ self._model = model
105
+ else:
106
+ from FlagEmbedding import BGEM3FlagModel
107
+
108
+ self._model = BGEM3FlagModel(model_name, use_fp16=use_fp16, devices=_resolve_device(device))
109
+
110
+ def encode_dense(self, text: str) -> list[float]:
111
+ with self._lock: # issue 0016
112
+ out = self._model.encode([text], return_dense=True, return_sparse=False)
113
+ return out["dense_vecs"][0].tolist()
114
+
115
+ def encode_sparse(self, text: str) -> dict[int, float]:
116
+ with self._lock: # issue 0016
117
+ out = self._model.encode([text], return_dense=False, return_sparse=True)
118
+ # lexical_weights is a Dict[str, float] keyed by string token ids; convert to int keys (T3).
119
+ return {int(k): float(v) for k, v in out["lexical_weights"][0].items()}
120
+
121
+ def encode_batch(self, texts: list[str]) -> tuple[list[list[float]], list[dict[int, float]]]:
122
+ """Batched dense+sparse over many texts in ONE model call (the ingestion/population path):
123
+ same per-text format as `encode_dense`/`encode_sparse`, amortizing the model overhead."""
124
+ if not texts:
125
+ return [], []
126
+ with self._lock: # issue 0016: serialize the shared-model encode across concurrent-document threads
127
+ out = self._model.encode(texts, return_dense=True, return_sparse=True, batch_size=self._batch_size)
128
+ dense = [v.tolist() for v in out["dense_vecs"]]
129
+ sparse = [{int(k): float(v) for k, v in lw.items()} for lw in out["lexical_weights"]]
130
+ return dense, sparse
131
+
132
+
133
+ async def embed_chunks(
134
+ chunks: list[Chunk],
135
+ *,
136
+ embedder: Embedder,
137
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
138
+ ) -> list[ChunkEmbedding]:
139
+ """Embed chunks concurrently (dense over summary, sparse over full text), backpressured.
140
+
141
+ Each chunk's encodes run in a thread (the poolable inference boundary); a semaphore bounds the
142
+ in-flight work so bulk mode saturates the GPU without unbounded concurrency.
143
+ """
144
+ semaphore = asyncio.Semaphore(max_concurrency)
145
+
146
+ async def _embed(chunk: Chunk) -> ChunkEmbedding:
147
+ async with semaphore: # backpressure
148
+ dense = await asyncio.to_thread(embedder.encode_dense, chunk.summary)
149
+ sparse = await asyncio.to_thread(embedder.encode_sparse, chunk.text)
150
+ return ChunkEmbedding(chunk_id=chunk.chunk_id, dense_vector=dense, sparse_vector=sparse)
151
+
152
+ return list(await asyncio.gather(*(_embed(chunk) for chunk in chunks)))
153
+
154
+
155
+ def embed_chunks_sync(
156
+ chunks: list[Chunk],
157
+ *,
158
+ embedder: Embedder,
159
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
160
+ ) -> list[ChunkEmbedding]:
161
+ """Synchronous convenience for callers not already in an event loop."""
162
+ return asyncio.run(embed_chunks(chunks, embedder=embedder, max_concurrency=max_concurrency))
163
+
164
+
@@ -0,0 +1,43 @@
1
+ """EP-API-4b (ADR-0117): embedding profiles -- the single place an embedding profile alias maps to its embedders.
2
+
3
+ A product selects a profile via `EngineConfig.embeddings["text"]`; the engine resolves it to the right QUERY-side
4
+ encoder (retrieval) and INGEST-side SPAN encoder (the index's `encode_batch`). `bge-m3` is the default BGE-M3
5
+ family (the query encoder + the batch SPAN encoder). Adding a profile is one entry in each registry here, invisible
6
+ to the product -- so BGE-M3 is no longer hardcoded in the workspace / the ingestion pipeline. Builders import their
7
+ heavy impls LAZILY, so importing this module (and passing a profile string around) stays cheap."""
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable
11
+
12
+
13
+ def _bge_m3_query() -> Any:
14
+ from rag_wright.capabilities.remote_encoders import query_embedder
15
+
16
+ return query_embedder()
17
+
18
+
19
+ def _bge_m3_ingest() -> Any:
20
+ from rag_wright.capabilities.embedding import BGEM3Embedder
21
+
22
+ return BGEM3Embedder()
23
+
24
+
25
+ # profile alias -> builder. A new embedder family = one entry in each (same alias).
26
+ _QUERY_BUILDERS: dict[str, Callable[[], Any]] = {"bge-m3": _bge_m3_query}
27
+ _INGEST_BUILDERS: dict[str, Callable[[], Any]] = {"bge-m3": _bge_m3_ingest}
28
+
29
+
30
+ def build_query_embedder(profile: str = "bge-m3") -> Any:
31
+ """The retrieval-side embedder for `profile` (default BGE-M3). Raises `ValueError` for an unknown profile."""
32
+ builder = _QUERY_BUILDERS.get(profile)
33
+ if builder is None:
34
+ raise ValueError(f"unsupported embedding profile {profile!r} (known: {sorted(_QUERY_BUILDERS)})")
35
+ return builder()
36
+
37
+
38
+ def build_ingest_embedder(profile: str = "bge-m3") -> Any:
39
+ """The ingest-side SPAN embedder for `profile` (default BGE-M3; `encode_batch`). Raises on an unknown profile."""
40
+ builder = _INGEST_BUILDERS.get(profile)
41
+ if builder is None:
42
+ raise ValueError(f"unsupported embedding profile {profile!r} (known: {sorted(_INGEST_BUILDERS)})")
43
+ return builder()
@@ -0,0 +1,154 @@
1
+ """Entity resolution (FR-C.7, T24): link canonical mention clusters to a canonical-id registry (closed-world).
2
+
3
+ The closed-world linking stage after canonicalization (T23b). It resolves each `MentionCluster` to its
4
+ canonical `entity_id` (the registry's canonical id) against the injected registry, and resolves the relationship endpoints
5
+ (the `RelationshipFact` `source_ref`/`target_ref` refs) as the SAME stream, so an entity that appears
6
+ both as a standalone mention and as a relationship endpoint lands on one node, not two (FR-C.7, ADR-0004).
7
+
8
+ Matching strategy (§16.3, ADR-0013): exact normalized-surface-form match against the registry,
9
+ closed-world — an unknown surface form resolves to `None` (unlinked), never a fabricated id. No fuzzy /
10
+ embedding / LLM matching: a wrong fuzzy link is a silent false merge onto a canonical entity, worse than
11
+ leaving a cluster unlinked (which the human sees), and T23b already collapsed the surface variants, so
12
+ exact-normalized is high-recall for entities the registry knows. Two post-resolution invariants live
13
+ here, not in the T4 contract, because both need resolved ids: a relationship whose two refs resolve to
14
+ the SAME entity_id is dropped as a self-loop, and the two mention channels are deduped across each other.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import defaultdict
20
+ from collections.abc import Sequence
21
+ from typing import Optional
22
+
23
+ from pydantic import BaseModel
24
+
25
+ from rag_wright.capabilities.disambiguation import DisambiguationResult, MentionCluster
26
+ from rag_wright.capabilities.registry import CapabilityRegistry
27
+ from rag_wright.contracts.extraction import ExtractionResult
28
+ from rag_wright.contracts.provenance import ConfidenceTag
29
+ from rag_wright.corpus.canonicalize import is_entity, normalize_entity_name
30
+ from rag_wright.ontology.registry import EntityResolver
31
+
32
+
33
+ class ResolvedEntity(BaseModel):
34
+ """A mention cluster linked (or not) to a canonical registry id. `entity_id is None` = unlinked
35
+ (closed-world: not in the registry — expected for private/unknown entities — never fabricated)."""
36
+
37
+ key: str
38
+ representative: str
39
+ entity_type: str # opaque domain entity type (DD-5); carried through from the cluster
40
+ entity_id: Optional[str] # the registry's canonical id, or None (unlinked)
41
+ confidence: ConfidenceTag
42
+ chunk_ids: list[str]
43
+
44
+
45
+ class ResolvedRelationship(BaseModel):
46
+ """A relationship whose endpoints are resolved to canonical ids (or None). Self-loops are dropped
47
+ before this is emitted (a relationship whose refs resolve to the same entity_id)."""
48
+
49
+ source_ref: str
50
+ target_ref: str
51
+ source_id: Optional[str]
52
+ target_id: Optional[str]
53
+ relationship_type: str # opaque domain edge type (DD-5); carried through from the fact
54
+ confidence: ConfidenceTag
55
+ chunk_id: str
56
+
57
+
58
+ class ResolutionResult(BaseModel):
59
+ """The capability's output: resolved entities and relationships (self-loops removed)."""
60
+
61
+ entities: list[ResolvedEntity]
62
+ relationships: list[ResolvedRelationship]
63
+
64
+
65
+ def _resolve_cluster(cluster: MentionCluster, resolver: EntityResolver) -> Optional[str]:
66
+ """Resolve a cluster to a canonical id: the first of its surface forms the resolver knows (closed-world)."""
67
+ for surface in (cluster.representative, *cluster.variants):
68
+ entity_id = resolver.resolve(surface)
69
+ if entity_id is not None:
70
+ return entity_id.value
71
+ return None
72
+
73
+
74
+ def resolve_entities(
75
+ clusters: DisambiguationResult,
76
+ results: Sequence[ExtractionResult],
77
+ *,
78
+ resolver: EntityResolver,
79
+ ) -> ResolutionResult:
80
+ """Link clusters to canonical ids and resolve relationship endpoints as one stream (self-loops dropped).
81
+
82
+ Each cluster resolves to a canonical id (or None) via the injected `EntityResolver` seam (DD-3) — the
83
+ resolution STRATEGY is the domain's concern, not this capability's. A relationship ref resolves by matching
84
+ a cluster key first — so a ref that is the same entity as a standalone mention takes that cluster's id (the
85
+ two-channel dedup, ADR-0004) — falling back to a direct resolver lookup only for a ref with no cluster. A
86
+ relationship whose two refs resolve to the same non-None id is dropped (self-loop). These invariants are
87
+ domain-neutral and stay here; only the surface-form lookup is delegated to the resolver.
88
+ """
89
+ entities: list[ResolvedEntity] = []
90
+ key_to_id: dict[str, Optional[str]] = {}
91
+ for cluster in clusters.clusters:
92
+ entity_id = _resolve_cluster(cluster, resolver)
93
+ key_to_id[cluster.key] = entity_id # channel-unification map (may be None: same unlinked entity)
94
+ entities.append(
95
+ ResolvedEntity(
96
+ key=cluster.key, representative=cluster.representative,
97
+ entity_type=cluster.entity_type, entity_id=entity_id,
98
+ confidence=cluster.confidence, chunk_ids=cluster.chunk_ids,
99
+ )
100
+ )
101
+
102
+ def _resolve_ref(ref: str) -> Optional[str]:
103
+ if not is_entity(ref):
104
+ return None
105
+ key = normalize_entity_name(ref)
106
+ if key in key_to_id: # same entity as a standalone mention -> its id (even if None)
107
+ return key_to_id[key]
108
+ resolved = resolver.resolve(ref)
109
+ return resolved.value if resolved is not None else None
110
+
111
+ relationships: list[ResolvedRelationship] = []
112
+ for result in results:
113
+ chunk_id = result.chunk_id.value
114
+ for fact in result.relationship_facts:
115
+ source_id = _resolve_ref(fact.source_ref)
116
+ target_id = _resolve_ref(fact.target_ref)
117
+ if source_id is not None and source_id == target_id:
118
+ continue # post-resolution self-loop: two distinct mentions, one entity -> dropped
119
+ relationships.append(
120
+ ResolvedRelationship(
121
+ source_ref=fact.source_ref, target_ref=fact.target_ref,
122
+ source_id=source_id, target_id=target_id,
123
+ relationship_type=fact.relationship_type, confidence=fact.confidence,
124
+ chunk_id=chunk_id,
125
+ )
126
+ )
127
+ return ResolutionResult(entities=entities, relationships=relationships)
128
+
129
+
130
+ def fragmentation_rate(result: ResolutionResult, gold_by_key: dict[str, str]) -> float:
131
+ """Residual fragmentation after resolution (risk 5), measured against gold entity labels: the
132
+ fraction of true entities that end as more than one node. Resolution reduces fragmentation when the
133
+ registry links surface variants T23b's exact-key clustering left separate (e.g. an acronym alias) to
134
+ the same canonical id. A cluster is a node by its `entity_id` if linked, else by its own key (unlinked)."""
135
+ nodes_per_entity: dict[str, set[str]] = defaultdict(set)
136
+ for entity in result.entities:
137
+ gold = gold_by_key.get(entity.key)
138
+ if gold is None:
139
+ continue
140
+ nodes_per_entity[gold].add(entity.entity_id or f"unlinked:{entity.key}")
141
+ if not nodes_per_entity:
142
+ return 0.0
143
+ fragmented = sum(1 for nodes in nodes_per_entity.values() if len(nodes) > 1)
144
+ return fragmented / len(nodes_per_entity)
145
+
146
+
147
+ def register_entity_resolution(registry: CapabilityRegistry) -> None:
148
+ """Register under FR-C.7 (`entity_resolution`, an in-process `function`)."""
149
+ registry.register(
150
+ "entity_resolution",
151
+ contract=ResolutionResult,
152
+ kind="function",
153
+ display_name="Entity resolution (closed-world to a canonical-id registry)",
154
+ )