keble-idea 0.3.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.
keble_idea/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """Public provider-neutral contracts for Keble Idea."""
2
+
3
+ from .application.services import (
4
+ ConceptAliasService,
5
+ ExtractionService,
6
+ IdeaSearchService,
7
+ )
8
+ from .domain.content import ContentCreate, ContentMongoObject
9
+ from .domain.idea import IdeaCreate, IdeaMongoObject
10
+ from .domain.job import (
11
+ ActiveProviderRecoveryProfilePointer,
12
+ EnvelopeManualRetryCommand,
13
+ EnvelopeProcessResult,
14
+ IdeaJobStatus,
15
+ IdeaJobType,
16
+ JobFailureSnapshot,
17
+ JobRecoveryDecision,
18
+ JobRecoveryDecisionReason,
19
+ ProviderPaymentEvidenceSource,
20
+ ProviderRecoveryProfileActivationCommand,
21
+ ProviderRecoveryProfileCreate,
22
+ ProviderRecoveryProfileMongoObject,
23
+ ProviderRecoveryProfileSnapshot,
24
+ QueuedEnvelopeCreate,
25
+ QueuedEnvelopeMongoObject,
26
+ UpstreamFailureEvidence,
27
+ UpstreamFailureEvidenceSource,
28
+ default_provider_recovery_profile_snapshot,
29
+ recovery_decision_for_failure,
30
+ recovery_profile_for_manual_retry,
31
+ retry_at_for_failure,
32
+ )
33
+ from .domain.search import IdeaSearchRequest, IdeaSearchResponse
34
+
35
+ __all__ = [
36
+ "ActiveProviderRecoveryProfilePointer",
37
+ "ConceptAliasService",
38
+ "ContentCreate",
39
+ "ContentMongoObject",
40
+ "ExtractionService",
41
+ "EnvelopeManualRetryCommand",
42
+ "EnvelopeProcessResult",
43
+ "IdeaCreate",
44
+ "IdeaMongoObject",
45
+ "IdeaJobStatus",
46
+ "IdeaJobType",
47
+ "IdeaSearchRequest",
48
+ "IdeaSearchResponse",
49
+ "IdeaSearchService",
50
+ "JobFailureSnapshot",
51
+ "JobRecoveryDecision",
52
+ "JobRecoveryDecisionReason",
53
+ "ProviderPaymentEvidenceSource",
54
+ "ProviderRecoveryProfileActivationCommand",
55
+ "ProviderRecoveryProfileCreate",
56
+ "ProviderRecoveryProfileMongoObject",
57
+ "ProviderRecoveryProfileSnapshot",
58
+ "QueuedEnvelopeCreate",
59
+ "QueuedEnvelopeMongoObject",
60
+ "UpstreamFailureEvidence",
61
+ "UpstreamFailureEvidenceSource",
62
+ "default_provider_recovery_profile_snapshot",
63
+ "recovery_decision_for_failure",
64
+ "recovery_profile_for_manual_retry",
65
+ "retry_at_for_failure",
66
+ ]
@@ -0,0 +1 @@
1
+ """Provider-neutral use cases and caller-owned protocols."""
@@ -0,0 +1,251 @@
1
+ """Canonical generation cutover and relationship lifecycle use cases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from pydantic import Field
8
+
9
+ from keble_idea.application.ports import (
10
+ ConceptPostingCompactorProtocol,
11
+ GenerationRegistryProtocol,
12
+ RelationshipInferenceProtocol,
13
+ RelationshipRepositoryProtocol,
14
+ )
15
+ from keble_idea.domain.base import IdeaSchema
16
+ from keble_idea.domain.concept import NeighborSummary
17
+ from keble_idea.domain.errors import IdeaDomainError, IdeaErrorCode, ObjectNotFoundError
18
+ from keble_idea.domain.generation import (
19
+ ActiveGenerationPointer,
20
+ GenerationCutoverCommand,
21
+ GenerationLifecycle,
22
+ GenerationRollbackCommand,
23
+ )
24
+ from keble_idea.domain.idea import IdeaConfidenceBand
25
+ from keble_idea.domain.job import EnvelopeExecutionIdentity
26
+ from keble_idea.domain.relationship import (
27
+ ConceptRelationshipCreate,
28
+ RelationshipCandidate,
29
+ RelationshipCoverage,
30
+ )
31
+ from keble_idea.processors.identity import build_record_key
32
+
33
+
34
+ class RelationshipRunContext(IdeaSchema):
35
+ """Immutable policy and model identity for one bounded inference batch."""
36
+
37
+ relationship_generation: str = Field(min_length=1, max_length=128)
38
+ embedding_generation: str = Field(min_length=1, max_length=128)
39
+ neighborhood_generation: str = Field(min_length=1, max_length=128)
40
+ model_id: str = Field(min_length=1, max_length=256)
41
+ prompt_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
42
+ policy_version: str = Field(min_length=1, max_length=128)
43
+ acceptance_threshold: float = Field(default=0.8, ge=0.65, le=1)
44
+ retirement_absence_sweeps: int = Field(default=2, ge=2, le=10)
45
+ execution: EnvelopeExecutionIdentity
46
+ processed_at: datetime
47
+
48
+
49
+ class RelationshipInferenceService:
50
+ """Infer exact candidate pairs and persist accepted edges plus coverage.
51
+
52
+ Steps:
53
+ 1. enforce unique deterministic concept pairs;
54
+ 2. accept null/no-relation outputs as successful evaluations;
55
+ 3. persist only outputs above the configured precision threshold;
56
+ 4. stamp exact evaluated-neighbor coverage after every pair completes.
57
+
58
+ Side effects if changes:
59
+ - relationship workers and evals share this acceptance behavior;
60
+ - Mongo is canonical and Neo4j reconciliation consumes accepted edges.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ inference: RelationshipInferenceProtocol,
67
+ relationships: RelationshipRepositoryProtocol,
68
+ ) -> None:
69
+ self._inference = inference
70
+ self._relationships = relationships
71
+
72
+ async def ainfer(
73
+ self,
74
+ *,
75
+ concept_key: str,
76
+ candidates: list[RelationshipCandidate],
77
+ context: RelationshipRunContext,
78
+ ) -> RelationshipCoverage:
79
+ """Evaluate each unique pair and persist deterministic accepted relations."""
80
+
81
+ pair_keys = [(row.left_concept_key, row.right_concept_key) for row in candidates]
82
+ if len(pair_keys) != len(set(pair_keys)):
83
+ raise ValueError("relationship candidates must contain unique concept pairs")
84
+ evaluated_neighbors: dict[str, NeighborSummary] = {}
85
+ for candidate in candidates:
86
+ if concept_key not in {candidate.left_concept_key, candidate.right_concept_key}:
87
+ raise ValueError("coverage concept_key must participate in every candidate")
88
+ if (
89
+ candidate.embedding_generation != context.embedding_generation
90
+ or candidate.neighborhood_generation != context.neighborhood_generation
91
+ ):
92
+ raise ValueError("candidate generations must match the relationship run context")
93
+ output = await self._inference.ainfer(
94
+ candidate=candidate,
95
+ execution=context.execution,
96
+ )
97
+ if (
98
+ output.left_concept_key != candidate.left_concept_key
99
+ or output.right_concept_key != candidate.right_concept_key
100
+ ):
101
+ raise ValueError("relationship inference returned an unknown concept pair")
102
+ neighbor_key = (
103
+ candidate.right_concept_key
104
+ if candidate.left_concept_key == concept_key
105
+ else candidate.left_concept_key
106
+ )
107
+ evaluated_neighbors[neighbor_key] = NeighborSummary(
108
+ concept_key=neighbor_key,
109
+ similarity=candidate.similarity,
110
+ )
111
+ if output.relation_type is None or output.confidence is None:
112
+ continue
113
+ if output.confidence < context.acceptance_threshold:
114
+ continue
115
+ relationship_key = build_record_key(
116
+ prefix="relationship",
117
+ identity={
118
+ "left_concept_key": output.left_concept_key,
119
+ "right_concept_key": output.right_concept_key,
120
+ "relation_type": output.relation_type.value,
121
+ "relationship_generation": context.relationship_generation,
122
+ },
123
+ )
124
+ confidence_band = (
125
+ IdeaConfidenceBand.HIGH
126
+ if output.confidence >= 0.9
127
+ else IdeaConfidenceBand.MEDIUM
128
+ )
129
+ relationship = ConceptRelationshipCreate(
130
+ record_key=relationship_key,
131
+ pipeline_generation=context.relationship_generation,
132
+ relationship_key=relationship_key,
133
+ left_concept_key=output.left_concept_key,
134
+ right_concept_key=output.right_concept_key,
135
+ relation_type=output.relation_type,
136
+ confidence_score=output.confidence,
137
+ confidence_band=confidence_band,
138
+ inference_basis=candidate.inference_basis,
139
+ evidence_refs=output.evidence_refs,
140
+ embedding_generation=candidate.embedding_generation,
141
+ neighborhood_generation=candidate.neighborhood_generation,
142
+ relationship_generation=context.relationship_generation,
143
+ model_id=context.model_id,
144
+ prompt_hash=context.prompt_hash,
145
+ policy_version=context.policy_version,
146
+ first_observed_at=context.processed_at,
147
+ last_observed_at=context.processed_at,
148
+ )
149
+ await self._relationships.aupsert(relationship=relationship)
150
+ coverage = RelationshipCoverage(
151
+ concept_key=concept_key,
152
+ embedding_generation=context.embedding_generation,
153
+ neighborhood_generation=context.neighborhood_generation,
154
+ relationship_generation=context.relationship_generation,
155
+ evaluated_neighbors=list(evaluated_neighbors.values()),
156
+ retirement_absence_sweeps=context.retirement_absence_sweeps,
157
+ covered_at=context.processed_at,
158
+ )
159
+ await self._relationships.aset_coverage(coverage=coverage)
160
+ return coverage
161
+
162
+
163
+ class GenerationLifecycleService:
164
+ """Register, verify, atomically cut over, and rollback immutable generations.
165
+
166
+ Side effects if changes:
167
+ - active search/runtime pointers change only through this service;
168
+ - Qdrant alias switching and projection verification occur in outer adapters;
169
+ - retention preserves the pointer's active and previous generations.
170
+ """
171
+
172
+ def __init__(self, *, registry: GenerationRegistryProtocol) -> None:
173
+ self._registry = registry
174
+
175
+ async def acutover(self, *, command: GenerationCutoverCommand) -> ActiveGenerationPointer:
176
+ """Switch one component only after its immutable generation is verified."""
177
+
178
+ generation = await self._registry.aget_generation(
179
+ generation_key=command.generation_key
180
+ )
181
+ if generation is None:
182
+ raise ObjectNotFoundError(
183
+ object_name="Generation",
184
+ object_key=command.generation_key,
185
+ )
186
+ if generation.component is not command.component:
187
+ raise ValueError("generation component does not match cutover component")
188
+ if generation.lifecycle not in {GenerationLifecycle.VERIFIED, GenerationLifecycle.ACTIVE}:
189
+ raise IdeaDomainError(
190
+ code=IdeaErrorCode.GENERATION_NOT_VERIFIED,
191
+ message=f"generation is not verified: {command.generation_key}",
192
+ )
193
+ return await self._registry.acompare_and_switch(command=command)
194
+
195
+ async def arollback(
196
+ self,
197
+ *,
198
+ command: GenerationRollbackCommand,
199
+ ) -> ActiveGenerationPointer:
200
+ """Restore the immediately previous verified generation atomically."""
201
+
202
+ pointer = await self._registry.aget_pointer(component=command.component)
203
+ if pointer is None or pointer.previous_generation_key is None:
204
+ raise IdeaDomainError(
205
+ code=IdeaErrorCode.GENERATION_NOT_VERIFIED,
206
+ message=f"no rollback generation for {command.component.value}",
207
+ )
208
+ previous = await self._registry.aget_generation(
209
+ generation_key=pointer.previous_generation_key
210
+ )
211
+ if previous is None or previous.verified_at is None:
212
+ raise IdeaDomainError(
213
+ code=IdeaErrorCode.GENERATION_NOT_VERIFIED,
214
+ message=f"rollback generation is not verified: {pointer.previous_generation_key}",
215
+ )
216
+ return await self._registry.acompare_and_rollback(command=command)
217
+
218
+
219
+ class PostingCompactionService:
220
+ """Rewrite a bounded alias posting page without blocking merge/unmerge."""
221
+
222
+ def __init__(self, *, compactor: ConceptPostingCompactorProtocol) -> None:
223
+ self._compactor = compactor
224
+
225
+ async def acompact(
226
+ self,
227
+ *,
228
+ source_concept_key: str,
229
+ canonical_concept_key: str,
230
+ after_posting_key: str | None,
231
+ limit: int = 250,
232
+ ) -> str | None:
233
+ """Compact one indexed page and return the next resumable cursor."""
234
+
235
+ if source_concept_key == canonical_concept_key:
236
+ raise ValueError("compaction source and canonical concepts must differ")
237
+ if limit < 1 or limit > 1_000:
238
+ raise ValueError("compaction limit must be between 1 and 1000")
239
+ return await self._compactor.acompact_page(
240
+ source_concept_key=source_concept_key,
241
+ canonical_concept_key=canonical_concept_key,
242
+ after_posting_key=after_posting_key,
243
+ limit=limit,
244
+ )
245
+
246
+ async def aclear(self, *, source_concept_key: str) -> int:
247
+ """Remove only derived alias rows after unmerge; preserve original postings."""
248
+
249
+ return await self._compactor.aclear_compacted(
250
+ source_concept_key=source_concept_key
251
+ )