rememberstack 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.
- rememberstack/__init__.py +9 -0
- rememberstack/adapters/__init__.py +42 -0
- rememberstack/adapters/codex_writer.py +221 -0
- rememberstack/adapters/markitdown_converter.py +42 -0
- rememberstack/adapters/openrouter.py +136 -0
- rememberstack/adapters/selfhost/__init__.py +54 -0
- rememberstack/adapters/selfhost/forget.py +66 -0
- rememberstack/adapters/selfhost/git.py +374 -0
- rememberstack/adapters/selfhost/lance.py +328 -0
- rememberstack/adapters/selfhost/minio.py +279 -0
- rememberstack/adapters/selfhost/mounts.py +249 -0
- rememberstack/adapters/selfhost/object_store.py +130 -0
- rememberstack/adapters/selfhost/projection.py +80 -0
- rememberstack/adapters/selfhost/queue.py +137 -0
- rememberstack/adapters/selfhost/telemetry.py +45 -0
- rememberstack/adapters/selfhost/watcher.py +70 -0
- rememberstack/adapters/testing/__init__.py +15 -0
- rememberstack/adapters/testing/cost_meter.py +13 -0
- rememberstack/adapters/testing/model_provider.py +83 -0
- rememberstack/adapters/testing/queue.py +43 -0
- rememberstack/adapters/testing/telemetry.py +22 -0
- rememberstack/client.py +19 -0
- rememberstack/core/__init__.py +127 -0
- rememberstack/core/blockizer.py +189 -0
- rememberstack/core/chunker.py +216 -0
- rememberstack/core/consumption_skill.py +275 -0
- rememberstack/core/conversion.py +76 -0
- rememberstack/core/core_manifest.py +598 -0
- rememberstack/core/extension_packs.py +124 -0
- rememberstack/core/forget.py +17 -0
- rememberstack/core/knowledge_authored.py +276 -0
- rememberstack/core/knowledge_compile.py +215 -0
- rememberstack/core/knowledge_fact_sheet.py +210 -0
- rememberstack/core/knowledge_hashing.py +68 -0
- rememberstack/core/knowledge_planner.py +64 -0
- rememberstack/core/knowledge_writer.py +175 -0
- rememberstack/core/ranking.py +200 -0
- rememberstack/core/recipe_linter.py +149 -0
- rememberstack/core/section_snap.py +209 -0
- rememberstack/core/storage_routing.py +27 -0
- rememberstack/eval/__init__.py +53 -0
- rememberstack/eval/consumption.py +141 -0
- rememberstack/eval/contradiction.py +184 -0
- rememberstack/eval/harness.py +136 -0
- rememberstack/eval/lifecycle.py +400 -0
- rememberstack/eval/operational_scale.py +49 -0
- rememberstack/eval/resolution.py +255 -0
- rememberstack/eval/retrieval_spikes.py +50 -0
- rememberstack/eval/skeleton.py +231 -0
- rememberstack/llm/__init__.py +1 -0
- rememberstack/model/__init__.py +589 -0
- rememberstack/model/adjudication.py +100 -0
- rememberstack/model/auth.py +27 -0
- rememberstack/model/blocks.py +30 -0
- rememberstack/model/chunks.py +190 -0
- rememberstack/model/claims.py +162 -0
- rememberstack/model/client.py +98 -0
- rememberstack/model/clustering.py +54 -0
- rememberstack/model/component_version.py +124 -0
- rememberstack/model/consumption.py +88 -0
- rememberstack/model/conversion.py +31 -0
- rememberstack/model/deployment.py +53 -0
- rememberstack/model/documents.py +168 -0
- rememberstack/model/envelope.py +513 -0
- rememberstack/model/evaluation.py +72 -0
- rememberstack/model/forget.py +143 -0
- rememberstack/model/git.py +13 -0
- rememberstack/model/knowledge.py +840 -0
- rememberstack/model/knowledge_authored.py +325 -0
- rememberstack/model/knowledge_planner.py +431 -0
- rememberstack/model/lifecycle.py +42 -0
- rememberstack/model/model_provider.py +78 -0
- rememberstack/model/mounts.py +24 -0
- rememberstack/model/object_store.py +21 -0
- rememberstack/model/operational_scale.py +59 -0
- rememberstack/model/operations.py +153 -0
- rememberstack/model/processing.py +228 -0
- rememberstack/model/queue.py +73 -0
- rememberstack/model/recipes.py +83 -0
- rememberstack/model/relations.py +79 -0
- rememberstack/model/resolution.py +83 -0
- rememberstack/model/retrieval_spikes.py +62 -0
- rememberstack/model/sections.py +120 -0
- rememberstack/model/telemetry.py +30 -0
- rememberstack/ports/__init__.py +29 -0
- rememberstack/ports/auth.py +16 -0
- rememberstack/ports/connector.py +23 -0
- rememberstack/ports/cost_meter.py +17 -0
- rememberstack/ports/forget.py +20 -0
- rememberstack/ports/git.py +20 -0
- rememberstack/ports/model_provider.py +28 -0
- rememberstack/ports/mounts.py +16 -0
- rememberstack/ports/object_store.py +27 -0
- rememberstack/ports/p1_index.py +92 -0
- rememberstack/ports/purge.py +93 -0
- rememberstack/ports/queue.py +23 -0
- rememberstack/ports/telemetry.py +21 -0
- rememberstack/profiles/__init__.py +22 -0
- rememberstack/profiles/selfhost.py +324 -0
- rememberstack/profiles/selfhost_forget.py +158 -0
- rememberstack/profiles/selfhost_operations.py +95 -0
- rememberstack/py.typed +1 -0
- rememberstack/spine/__init__.py +93 -0
- rememberstack/spine/admission.py +26 -0
- rememberstack/spine/backfill.py +168 -0
- rememberstack/spine/catalog_contract.py +742 -0
- rememberstack/spine/chunk_catalog.py +237 -0
- rememberstack/spine/claim_catalog.py +298 -0
- rememberstack/spine/clustering.py +740 -0
- rememberstack/spine/component_versions.py +208 -0
- rememberstack/spine/consumption.py +81 -0
- rememberstack/spine/deployment_bootstrap.py +445 -0
- rememberstack/spine/document_catalog.py +621 -0
- rememberstack/spine/entity_registry.py +205 -0
- rememberstack/spine/extension_packs.py +220 -0
- rememberstack/spine/fact_catalog.py +571 -0
- rememberstack/spine/forget.py +1753 -0
- rememberstack/spine/knowledge.py +5467 -0
- rememberstack/spine/lifecycle.py +1071 -0
- rememberstack/spine/migrations/__init__.py +1 -0
- rememberstack/spine/migrations/_helpers.py +153 -0
- rememberstack/spine/migrations/env.py +58 -0
- rememberstack/spine/migrations/script.py.mako +27 -0
- rememberstack/spine/migrations/versions/__init__.py +1 -0
- rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
- rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
- rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
- rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
- rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
- rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
- rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
- rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
- rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
- rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
- rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
- rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
- rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
- rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
- rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
- rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
- rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
- rememberstack/spine/observation_adjudication.py +778 -0
- rememberstack/spine/operations.py +298 -0
- rememberstack/spine/projection.py +662 -0
- rememberstack/spine/recipes.py +276 -0
- rememberstack/spine/resolver.py +763 -0
- rememberstack/spine/review.py +650 -0
- rememberstack/spine/settings.py +22 -0
- rememberstack/spine/supersession.py +510 -0
- rememberstack/spine/sync.py +128 -0
- rememberstack/spine/work_ledger.py +816 -0
- rememberstack/surfaces/__init__.py +110 -0
- rememberstack/surfaces/cli.py +447 -0
- rememberstack/surfaces/consumption_skill.py +87 -0
- rememberstack/surfaces/graph_queries.py +698 -0
- rememberstack/surfaces/http_api.py +377 -0
- rememberstack/surfaces/mcp.py +67 -0
- rememberstack/surfaces/query_engine.py +1591 -0
- rememberstack/surfaces/recipe_executor.py +185 -0
- rememberstack/surfaces/recipe_surface.py +219 -0
- rememberstack/surfaces/remote_mcp.py +133 -0
- rememberstack/surfaces/sdk.py +324 -0
- rememberstack/workers/__init__.py +155 -0
- rememberstack/workers/base.py +312 -0
- rememberstack/workers/e0.py +577 -0
- rememberstack/workers/e1.py +425 -0
- rememberstack/workers/e2.py +525 -0
- rememberstack/workers/e3.py +434 -0
- rememberstack/workers/forget.py +299 -0
- rememberstack/workers/knowledge_authored.py +146 -0
- rememberstack/workers/knowledge_driver.py +735 -0
- rememberstack/workers/knowledge_fact_sheet.py +123 -0
- rememberstack/workers/knowledge_planner.py +325 -0
- rememberstack/workers/knowledge_writer.py +393 -0
- rememberstack/workers/operations.py +42 -0
- rememberstack/workers/p1.py +234 -0
- rememberstack/workers/p2.py +513 -0
- rememberstack/workers/p2_analytics.py +276 -0
- rememberstack/workers/p3.py +673 -0
- rememberstack/workers/reconcile.py +485 -0
- rememberstack/workers/sync.py +168 -0
- rememberstack-0.1.0.dist-info/METADATA +213 -0
- rememberstack-0.1.0.dist-info/RECORD +186 -0
- rememberstack-0.1.0.dist-info/WHEEL +4 -0
- rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
- rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1591 @@
|
|
|
1
|
+
"""The zero-LLM query engine (retrieval §2-§3): resolve, lookup, search, hydrate.
|
|
2
|
+
|
|
3
|
+
The one correctness rule is D48: projections (P1 Lance) may NOMINATE
|
|
4
|
+
candidates, but every returned record has passed by-ID hydration against the
|
|
5
|
+
live Postgres spine — a superseded fact can never be served as current, and
|
|
6
|
+
nominations hydration rejects are counted in `dropped_by_hydration` so ranked
|
|
7
|
+
results are honest about their denominator. No primitive calls an LLM; reads
|
|
8
|
+
never trigger anything.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from datetime import UTC
|
|
17
|
+
from itertools import batched
|
|
18
|
+
from typing import Final
|
|
19
|
+
from uuid import UUID
|
|
20
|
+
|
|
21
|
+
from sqlalchemy import text
|
|
22
|
+
from sqlalchemy import TextClause
|
|
23
|
+
from sqlalchemy.engine import Engine
|
|
24
|
+
from sqlalchemy.engine import RowMapping
|
|
25
|
+
|
|
26
|
+
from rememberstack.core.ranking import DEFAULT_RRF_K
|
|
27
|
+
from rememberstack.core.ranking import reciprocal_rank_fusion
|
|
28
|
+
from rememberstack.core.ranking import rerank_by_signal
|
|
29
|
+
from rememberstack.core.ranking import rerank_by_weighted_signals
|
|
30
|
+
from rememberstack.model import AggregateBucket
|
|
31
|
+
from rememberstack.model import AggregateReport
|
|
32
|
+
from rememberstack.model import ChangeRecord
|
|
33
|
+
from rememberstack.model import CoMember
|
|
34
|
+
from rememberstack.model import Contradiction
|
|
35
|
+
from rememberstack.model import EmbeddingRequest
|
|
36
|
+
from rememberstack.model import EntityCandidate
|
|
37
|
+
from rememberstack.model import Envelope
|
|
38
|
+
from rememberstack.model import EvidenceResult
|
|
39
|
+
from rememberstack.model import FactResult
|
|
40
|
+
from rememberstack.model import FactSupport
|
|
41
|
+
from rememberstack.model import Freshness
|
|
42
|
+
from rememberstack.model import Grain
|
|
43
|
+
from rememberstack.model import Negative
|
|
44
|
+
from rememberstack.model import NegativeKind
|
|
45
|
+
from rememberstack.model import PageRef
|
|
46
|
+
from rememberstack.model import RankedItem
|
|
47
|
+
from rememberstack.model import ScanRow
|
|
48
|
+
from rememberstack.model import SourceRecord
|
|
49
|
+
from rememberstack.model import TranscriptEntry
|
|
50
|
+
from rememberstack.model import Truncation
|
|
51
|
+
from rememberstack.model import Validity
|
|
52
|
+
from rememberstack.ports.model_provider import ModelProviderPort
|
|
53
|
+
from rememberstack.ports.p1_index import P1SearchPort
|
|
54
|
+
from rememberstack.spine.entity_registry import normalized_lemma
|
|
55
|
+
|
|
56
|
+
DEFAULT_DELTA_LIMIT = 500
|
|
57
|
+
"""How many change-feed rows one `delta` page returns before truncating —
|
|
58
|
+
a starting point to measure, not a committed constant (retrieval §13)."""
|
|
59
|
+
|
|
60
|
+
DEFAULT_SCAN_BATCH = 1_000
|
|
61
|
+
"""How many rows the batch `scan` cursor fetches per round-trip."""
|
|
62
|
+
|
|
63
|
+
CONTRADICTION_COMEMBER_CAP = 25
|
|
64
|
+
"""How many co-members a contradiction block returns inline before it pages
|
|
65
|
+
(S23). Typical groups are 2–3 sides, so the cap is rarely reached — but when
|
|
66
|
+
it is, the block still carries group_id/returned/total/continuation, never a
|
|
67
|
+
one-sided answer. WP-5.6 measured this starting cap below its explicit 16 KiB
|
|
68
|
+
inline-envelope budget; that budget is an operating target, not a protocol
|
|
69
|
+
limit."""
|
|
70
|
+
|
|
71
|
+
RESOLVE_CONTEXT_LIMIT: Final = 8
|
|
72
|
+
"""Maximum focal entities in WP-5.6's bounded S51 context tie-break."""
|
|
73
|
+
|
|
74
|
+
INTERACTIVE_HYDRATION_BATCH_SIZE: Final = 256
|
|
75
|
+
"""Maximum ids in one WP-5.6-measured Postgres confirmation hop."""
|
|
76
|
+
|
|
77
|
+
_RERANK_SIGNALS = {"graph_distance": True, "evidence_count": False}
|
|
78
|
+
"""The inspectable rerank signals and whether each sorts ascending: nearer
|
|
79
|
+
the focal entity wins (ascending), more corroboration wins (descending)."""
|
|
80
|
+
|
|
81
|
+
_BOUNDED_AGGREGATE_FORMS = frozenset(
|
|
82
|
+
{"group_by_predicate", "group_by_object", "delta_top_entities", "typed_absence"}
|
|
83
|
+
)
|
|
84
|
+
"""The aggregate forms that take a `limit` and so must disclose truncation.
|
|
85
|
+
`count` and `timeline` are naturally bounded (one row / one row per year)."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _encode_feed_cursor(*, at: datetime, item_id: UUID) -> str:
|
|
89
|
+
"""Pack a delta feed position into one opaque, resumable token."""
|
|
90
|
+
raw = f"{at.isoformat()}|{item_id}".encode()
|
|
91
|
+
return base64.urlsafe_b64encode(raw).decode()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _decode_feed_cursor(token: str | None) -> tuple[datetime, UUID] | None:
|
|
95
|
+
"""Unpack a feed cursor into (at, id), or None when there is no cursor."""
|
|
96
|
+
if token is None:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
raw = base64.urlsafe_b64decode(token.encode()).decode()
|
|
100
|
+
at_text, id_text = raw.rsplit("|", 1)
|
|
101
|
+
return (datetime.fromisoformat(at_text), UUID(id_text))
|
|
102
|
+
except (ValueError, binascii.Error) as error:
|
|
103
|
+
raise ValueError(f"invalid delta continuation: {token!r}") from error
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class QueryEngine:
|
|
107
|
+
"""The typed read path over one deployment's spine and P1 indexes."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
engine: Engine,
|
|
113
|
+
search_index: P1SearchPort,
|
|
114
|
+
model_provider: ModelProviderPort,
|
|
115
|
+
embedding_model: str,
|
|
116
|
+
batch_engine: Engine | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Bind the engine to the spine, the P1 indexes, and the embedder.
|
|
119
|
+
|
|
120
|
+
Embedding a query string is not an LLM call (retrieval §3): the
|
|
121
|
+
provider's embed endpoint is the semantic channel's entry.
|
|
122
|
+
|
|
123
|
+
`batch_engine` is the SEPARATE resource pool the batch surface uses
|
|
124
|
+
(retrieval §9): `scan`'s streaming exports run against it so a large
|
|
125
|
+
export can never starve the interactive connection pool. It defaults
|
|
126
|
+
to the interactive engine — correct for a single-pool deployment —
|
|
127
|
+
but a deployment that wants isolation passes a second engine bound
|
|
128
|
+
to its own connection pool.
|
|
129
|
+
"""
|
|
130
|
+
self._engine = engine
|
|
131
|
+
self._search_index = search_index
|
|
132
|
+
self._model_provider = model_provider
|
|
133
|
+
self._embedding_model = embedding_model
|
|
134
|
+
self._batch_engine = batch_engine or engine
|
|
135
|
+
|
|
136
|
+
def resolve(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
deployment_id: UUID,
|
|
140
|
+
name: str,
|
|
141
|
+
entity_type: str | None = None,
|
|
142
|
+
context_entity_ids: tuple[UUID, ...] = (),
|
|
143
|
+
) -> Envelope:
|
|
144
|
+
"""Resolve a name to ranked current entities (T0 in the skeleton).
|
|
145
|
+
|
|
146
|
+
Nothing resolving is the `unknown_entity` negative (S39) — the agent
|
|
147
|
+
widens resolution or searches; it never gets a silent guess (S51).
|
|
148
|
+
Optional focal entities only reorder exact-name candidates by current
|
|
149
|
+
relation adjacency; every candidate remains visible, so context can
|
|
150
|
+
narrow ambiguity without becoming a silent identity verdict.
|
|
151
|
+
"""
|
|
152
|
+
context_entity_ids = tuple(dict.fromkeys(context_entity_ids))
|
|
153
|
+
if len(context_entity_ids) > RESOLVE_CONTEXT_LIMIT:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"resolve context accepts at most {RESOLVE_CONTEXT_LIMIT} entities"
|
|
156
|
+
)
|
|
157
|
+
with self._engine.connect() as connection:
|
|
158
|
+
rows = (
|
|
159
|
+
connection.execute(
|
|
160
|
+
_RESOLVE_T0,
|
|
161
|
+
{
|
|
162
|
+
"deployment_id": deployment_id,
|
|
163
|
+
"lemma": normalized_lemma(surface=name),
|
|
164
|
+
"entity_type": entity_type,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
.mappings()
|
|
168
|
+
.all()
|
|
169
|
+
)
|
|
170
|
+
candidate_ids = tuple(row["entity_id"] for row in rows)
|
|
171
|
+
context_hits = (
|
|
172
|
+
{
|
|
173
|
+
row["candidate_id"]: int(row["context_hits"])
|
|
174
|
+
for row in connection.execute(
|
|
175
|
+
_RESOLVE_CONTEXT_HITS,
|
|
176
|
+
{
|
|
177
|
+
"deployment_id": deployment_id,
|
|
178
|
+
"candidate_ids": list(candidate_ids),
|
|
179
|
+
"context_entity_ids": list(context_entity_ids),
|
|
180
|
+
},
|
|
181
|
+
).mappings()
|
|
182
|
+
}
|
|
183
|
+
if candidate_ids and context_entity_ids
|
|
184
|
+
else {}
|
|
185
|
+
)
|
|
186
|
+
candidates = tuple(
|
|
187
|
+
EntityCandidate(
|
|
188
|
+
entity_id=row["entity_id"],
|
|
189
|
+
canonical_name=row["canonical_name"],
|
|
190
|
+
type=row["type"],
|
|
191
|
+
tier="T0",
|
|
192
|
+
context_hits=context_hits.get(row["entity_id"], 0),
|
|
193
|
+
)
|
|
194
|
+
for row in sorted(
|
|
195
|
+
rows,
|
|
196
|
+
key=lambda row: (
|
|
197
|
+
-context_hits.get(row["entity_id"], 0),
|
|
198
|
+
str(row["canonical_name"]),
|
|
199
|
+
row["entity_id"].bytes,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return Envelope(
|
|
204
|
+
grain=Grain.FACT,
|
|
205
|
+
entities=candidates,
|
|
206
|
+
freshness=_freshness(),
|
|
207
|
+
negative=None
|
|
208
|
+
if candidates
|
|
209
|
+
else Negative(
|
|
210
|
+
kind=NegativeKind.UNKNOWN_ENTITY,
|
|
211
|
+
explanation=f"nothing resolves for {name!r}",
|
|
212
|
+
workaround="check spelling, try search over claims or chunks",
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def lookup_relations(
|
|
217
|
+
self,
|
|
218
|
+
*,
|
|
219
|
+
deployment_id: UUID,
|
|
220
|
+
subject_entity_id: UUID | None = None,
|
|
221
|
+
predicate: str | None = None,
|
|
222
|
+
object_entity_id: UUID | None = None,
|
|
223
|
+
valid_at: datetime | None = None,
|
|
224
|
+
) -> Envelope:
|
|
225
|
+
"""Relations matching the (s, p, o) pattern — fact grain (S1/S3/S9).
|
|
226
|
+
|
|
227
|
+
Without `valid_at`, current means both clocks: still believed AND the
|
|
228
|
+
valid-time window covers now. With `valid_at`, the window test moves
|
|
229
|
+
to that instant (the S9-class as-of read; belief stays live — the
|
|
230
|
+
believed_at axis arrives with its own parameter). The applied instant
|
|
231
|
+
is echoed in the envelope. An existing entity with no matching facts
|
|
232
|
+
is `known_empty` (S39).
|
|
233
|
+
"""
|
|
234
|
+
as_of = valid_at or datetime.now(tz=UTC)
|
|
235
|
+
with self._engine.connect() as connection:
|
|
236
|
+
rows = (
|
|
237
|
+
connection.execute(
|
|
238
|
+
_LOOKUP_RELATIONS,
|
|
239
|
+
{
|
|
240
|
+
"deployment_id": deployment_id,
|
|
241
|
+
"subject_entity_id": subject_entity_id,
|
|
242
|
+
"predicate": predicate,
|
|
243
|
+
"object_entity_id": object_entity_id,
|
|
244
|
+
"as_of": as_of,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
.mappings()
|
|
248
|
+
.all()
|
|
249
|
+
)
|
|
250
|
+
facts = self._enrich_facts(
|
|
251
|
+
deployment_id=deployment_id,
|
|
252
|
+
facts=tuple(_fact_result(row=row, kind="relation") for row in rows),
|
|
253
|
+
kind="relation",
|
|
254
|
+
)
|
|
255
|
+
return Envelope(
|
|
256
|
+
grain=Grain.FACT,
|
|
257
|
+
as_of_valid_at=valid_at,
|
|
258
|
+
facts=facts,
|
|
259
|
+
freshness=_freshness(),
|
|
260
|
+
negative=None
|
|
261
|
+
if facts
|
|
262
|
+
else Negative(
|
|
263
|
+
kind=NegativeKind.KNOWN_EMPTY,
|
|
264
|
+
explanation="no live relations match the pattern",
|
|
265
|
+
workaround=None,
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def lookup_observations(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
deployment_id: UUID,
|
|
273
|
+
entity_id: UUID,
|
|
274
|
+
property_query: str | None = None,
|
|
275
|
+
k: int = 10,
|
|
276
|
+
valid_at: datetime | None = None,
|
|
277
|
+
) -> Envelope:
|
|
278
|
+
"""Observations on one entity — current, or as-of on the valid-time
|
|
279
|
+
axis (S2/S9, D43): "headcount mid-2024" is the capped slice whose
|
|
280
|
+
window covers that instant.
|
|
281
|
+
|
|
282
|
+
With a property query, the facts channel NOMINATES by label similarity
|
|
283
|
+
and the spine confirms live rows (D48); without one, the entity block
|
|
284
|
+
is read directly.
|
|
285
|
+
"""
|
|
286
|
+
dropped = 0
|
|
287
|
+
as_of = valid_at or datetime.now(tz=UTC)
|
|
288
|
+
if property_query is None:
|
|
289
|
+
with self._engine.connect() as connection:
|
|
290
|
+
rows = (
|
|
291
|
+
connection.execute(
|
|
292
|
+
_LOOKUP_OBSERVATIONS,
|
|
293
|
+
{
|
|
294
|
+
"deployment_id": deployment_id,
|
|
295
|
+
"entity_id": entity_id,
|
|
296
|
+
"as_of": as_of,
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
.mappings()
|
|
300
|
+
.all()
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
nominated = self._search_index.search_facts(
|
|
304
|
+
deployment_id=str(deployment_id),
|
|
305
|
+
vector=self._embed(query=property_query),
|
|
306
|
+
k=k,
|
|
307
|
+
kind="observation",
|
|
308
|
+
)
|
|
309
|
+
rows, dropped = self._confirm_observations(
|
|
310
|
+
deployment_id=deployment_id,
|
|
311
|
+
entity_id=entity_id,
|
|
312
|
+
observation_ids=tuple(UUID(item) for item in nominated),
|
|
313
|
+
as_of=as_of,
|
|
314
|
+
)
|
|
315
|
+
facts = self._enrich_facts(
|
|
316
|
+
deployment_id=deployment_id,
|
|
317
|
+
facts=tuple(_fact_result(row=row, kind="observation") for row in rows),
|
|
318
|
+
kind="observation",
|
|
319
|
+
)
|
|
320
|
+
return Envelope(
|
|
321
|
+
grain=Grain.FACT,
|
|
322
|
+
as_of_valid_at=valid_at,
|
|
323
|
+
facts=facts,
|
|
324
|
+
freshness=_freshness(),
|
|
325
|
+
dropped_by_hydration=dropped,
|
|
326
|
+
negative=None
|
|
327
|
+
if facts
|
|
328
|
+
else Negative(
|
|
329
|
+
kind=NegativeKind.KNOWN_EMPTY,
|
|
330
|
+
explanation="no live observations match on this entity",
|
|
331
|
+
workaround=None,
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def search_claims(
|
|
336
|
+
self, *, deployment_id: UUID, query: str, k: int = 10
|
|
337
|
+
) -> Envelope:
|
|
338
|
+
"""Semantic claim search — EVIDENCE grain, never a current-fact answer.
|
|
339
|
+
|
|
340
|
+
The claims channel nominates (current-testimony-only by default);
|
|
341
|
+
hydration re-reads each claim from the spine and drops what no longer
|
|
342
|
+
confirms, counting the drops (D48 nominate-then-drop honesty).
|
|
343
|
+
"""
|
|
344
|
+
nominated = self._search_index.search_claims(
|
|
345
|
+
deployment_id=str(deployment_id),
|
|
346
|
+
vector=self._embed(query=query),
|
|
347
|
+
k=k,
|
|
348
|
+
current_only=True,
|
|
349
|
+
)
|
|
350
|
+
evidence, dropped = self._confirm_claims(
|
|
351
|
+
deployment_id=deployment_id,
|
|
352
|
+
claim_ids=tuple(UUID(item) for item in nominated),
|
|
353
|
+
)
|
|
354
|
+
return Envelope(
|
|
355
|
+
grain=Grain.EVIDENCE,
|
|
356
|
+
evidence=evidence,
|
|
357
|
+
freshness=_freshness(),
|
|
358
|
+
dropped_by_hydration=dropped,
|
|
359
|
+
negative=None
|
|
360
|
+
if evidence
|
|
361
|
+
else Negative(
|
|
362
|
+
kind=NegativeKind.KNOWN_EMPTY,
|
|
363
|
+
explanation="no current-testimony claims match the query",
|
|
364
|
+
workaround="broaden the query or inspect the source artifacts",
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def hydrate_relation(self, *, deployment_id: UUID, relation_id: UUID) -> Envelope:
|
|
369
|
+
"""The S5 chain: relation → evidence claims → source documents.
|
|
370
|
+
|
|
371
|
+
Composite grain: the fact, its supporting evidence-grain claims
|
|
372
|
+
(verbatim spans and offsets against the representation they were cut
|
|
373
|
+
from), and the ID-addressed document handles. Hydrate-by-ID is the
|
|
374
|
+
AUDIT deepening hop: an invalidated relation is returned with its
|
|
375
|
+
invalidation disclosed in `validity` (D48 re-reads and discloses —
|
|
376
|
+
it does not refuse audit access); current-fact questions route
|
|
377
|
+
through lookup, which filters both clocks.
|
|
378
|
+
"""
|
|
379
|
+
with self._engine.connect() as connection:
|
|
380
|
+
relation = (
|
|
381
|
+
connection.execute(
|
|
382
|
+
_HYDRATE_RELATION,
|
|
383
|
+
{"deployment_id": deployment_id, "relation_id": relation_id},
|
|
384
|
+
)
|
|
385
|
+
.mappings()
|
|
386
|
+
.one_or_none()
|
|
387
|
+
)
|
|
388
|
+
if relation is None:
|
|
389
|
+
return Envelope(
|
|
390
|
+
grain=Grain.COMPOSITE,
|
|
391
|
+
freshness=_freshness(),
|
|
392
|
+
negative=Negative(
|
|
393
|
+
kind=NegativeKind.UNKNOWN_ENTITY,
|
|
394
|
+
explanation=f"relation {relation_id} does not exist",
|
|
395
|
+
workaround=None,
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
claims = (
|
|
399
|
+
connection.execute(
|
|
400
|
+
_HYDRATE_EVIDENCE_CLAIMS, {"relation_id": relation_id}
|
|
401
|
+
)
|
|
402
|
+
.mappings()
|
|
403
|
+
.all()
|
|
404
|
+
)
|
|
405
|
+
sources = (
|
|
406
|
+
connection.execute(_HYDRATE_SOURCES, {"relation_id": relation_id})
|
|
407
|
+
.mappings()
|
|
408
|
+
.all()
|
|
409
|
+
)
|
|
410
|
+
# the audit hop discloses the same S23 contradiction and D54 support
|
|
411
|
+
# as a lookup — a contradicted relation is never hydrated one-sided
|
|
412
|
+
facts = self._enrich_facts(
|
|
413
|
+
deployment_id=deployment_id,
|
|
414
|
+
facts=(_fact_result(row=relation, kind="relation"),),
|
|
415
|
+
kind="relation",
|
|
416
|
+
)
|
|
417
|
+
return Envelope(
|
|
418
|
+
grain=Grain.COMPOSITE,
|
|
419
|
+
facts=facts,
|
|
420
|
+
evidence=tuple(EvidenceResult.model_validate(dict(row)) for row in claims),
|
|
421
|
+
sources=tuple(SourceRecord.model_validate(dict(row)) for row in sources),
|
|
422
|
+
freshness=_freshness(),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def transcript(
|
|
426
|
+
self, *, deployment_id: UUID, subject_kind: str, subject_id: UUID
|
|
427
|
+
) -> Envelope:
|
|
428
|
+
"""The S8/S32/S35 audit query: any subject's decision history.
|
|
429
|
+
|
|
430
|
+
"Why do we believe this?" as a first-class read, uniform across the
|
|
431
|
+
four subjects a decision is about: a supersession-adjudicated
|
|
432
|
+
`relation` or `observation`, a resolved/merged `entity` (its
|
|
433
|
+
resolution decisions braided with its merges), or a compiled
|
|
434
|
+
`k_page` (its compile provenance). Returned newest-last; reads never
|
|
435
|
+
trigger anything. An empty history is `known_empty`, not a guess; an
|
|
436
|
+
unknown kind is a `boundary` naming the four that exist.
|
|
437
|
+
"""
|
|
438
|
+
statement = _TRANSCRIPT_BY_KIND.get(subject_kind)
|
|
439
|
+
if statement is None:
|
|
440
|
+
return Envelope(
|
|
441
|
+
grain=Grain.COMPOSITE,
|
|
442
|
+
freshness=_freshness(),
|
|
443
|
+
negative=Negative(
|
|
444
|
+
kind=NegativeKind.BOUNDARY,
|
|
445
|
+
explanation=(f"no transcript for subject kind {subject_kind!r}"),
|
|
446
|
+
workaround="use one of: relation, observation, entity, k_page",
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
with self._engine.connect() as connection:
|
|
450
|
+
rows = (
|
|
451
|
+
connection.execute(
|
|
452
|
+
statement,
|
|
453
|
+
{"deployment_id": deployment_id, "subject_id": subject_id},
|
|
454
|
+
)
|
|
455
|
+
.mappings()
|
|
456
|
+
.all()
|
|
457
|
+
)
|
|
458
|
+
return Envelope(
|
|
459
|
+
grain=Grain.COMPOSITE,
|
|
460
|
+
transcript=tuple(TranscriptEntry.model_validate(dict(row)) for row in rows),
|
|
461
|
+
freshness=_freshness(),
|
|
462
|
+
negative=None
|
|
463
|
+
if rows
|
|
464
|
+
else Negative(
|
|
465
|
+
kind=NegativeKind.KNOWN_EMPTY,
|
|
466
|
+
explanation=f"no decision history for this {subject_kind}",
|
|
467
|
+
workaround=None,
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def transcript_relation(
|
|
472
|
+
self, *, deployment_id: UUID, relation_id: UUID
|
|
473
|
+
) -> Envelope:
|
|
474
|
+
"""A relation's decision history — the `transcript` primitive, relation
|
|
475
|
+
arm (kept as the named surface the HTTP API and recipes bind to)."""
|
|
476
|
+
return self.transcript(
|
|
477
|
+
deployment_id=deployment_id, subject_kind="relation", subject_id=relation_id
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def fuse(
|
|
481
|
+
self, *, rankings: Sequence[Sequence[UUID]], k: int = DEFAULT_RRF_K
|
|
482
|
+
) -> Envelope:
|
|
483
|
+
"""RRF-merge parallel channel rankings into one order (D9/S46).
|
|
484
|
+
|
|
485
|
+
An operator, not a spine read: the same reciprocal-rank fusion a
|
|
486
|
+
recipe applies, exposed so an agent's ad-hoc channel set fuses
|
|
487
|
+
identically. The grain is EVIDENCE — a fused order is over
|
|
488
|
+
nominations still to be confirmed by id-hydration (D48), never
|
|
489
|
+
current-fact truth on its own.
|
|
490
|
+
"""
|
|
491
|
+
fused = reciprocal_rank_fusion(rankings=rankings, k=k)
|
|
492
|
+
return Envelope(
|
|
493
|
+
grain=Grain.EVIDENCE,
|
|
494
|
+
ranking=fused,
|
|
495
|
+
freshness=_freshness(),
|
|
496
|
+
negative=None
|
|
497
|
+
if fused
|
|
498
|
+
else Negative(
|
|
499
|
+
kind=NegativeKind.KNOWN_EMPTY,
|
|
500
|
+
explanation="no channel supplied any candidate to fuse",
|
|
501
|
+
workaround=None,
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def rerank(self, *, items: Sequence[RankedItem], signal: str) -> Envelope:
|
|
506
|
+
"""Reorder candidates by one inspectable signal (D9/S46/S48).
|
|
507
|
+
|
|
508
|
+
`graph_distance` and `evidence_count` are the direct signals;
|
|
509
|
+
`weighted_relevance` applies WP-5.6's measured normalized blend while
|
|
510
|
+
preserving every contribution on the item. `cross_encoder` needs a
|
|
511
|
+
configured reranker port and is off by default — asking for it, or
|
|
512
|
+
for any unknown signal, is a typed `boundary`, never a silent
|
|
513
|
+
identity sort.
|
|
514
|
+
"""
|
|
515
|
+
if signal == "cross_encoder":
|
|
516
|
+
return self._rerank_boundary(
|
|
517
|
+
explanation=(
|
|
518
|
+
"cross-encoder reranking needs a configured reranker port"
|
|
519
|
+
" and is off by default"
|
|
520
|
+
),
|
|
521
|
+
workaround=(
|
|
522
|
+
"use graph_distance, evidence_count, or weighted_relevance"
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
if signal == "weighted_relevance":
|
|
526
|
+
ranked = rerank_by_weighted_signals(items=items)
|
|
527
|
+
return Envelope(
|
|
528
|
+
grain=Grain.EVIDENCE, ranking=ranked, freshness=_freshness()
|
|
529
|
+
)
|
|
530
|
+
ascending = _RERANK_SIGNALS.get(signal)
|
|
531
|
+
if ascending is None:
|
|
532
|
+
return self._rerank_boundary(
|
|
533
|
+
explanation=f"no rerank signal {signal!r}",
|
|
534
|
+
workaround=(
|
|
535
|
+
"use graph_distance, evidence_count, or weighted_relevance"
|
|
536
|
+
),
|
|
537
|
+
)
|
|
538
|
+
ranked = rerank_by_signal(items=items, signal=signal, ascending=ascending)
|
|
539
|
+
return Envelope(grain=Grain.EVIDENCE, ranking=ranked, freshness=_freshness())
|
|
540
|
+
|
|
541
|
+
def delta(
|
|
542
|
+
self,
|
|
543
|
+
*,
|
|
544
|
+
deployment_id: UUID,
|
|
545
|
+
since: datetime,
|
|
546
|
+
kinds: tuple[str, ...] | None = None,
|
|
547
|
+
limit: int = DEFAULT_DELTA_LIMIT,
|
|
548
|
+
continuation: str | None = None,
|
|
549
|
+
) -> Envelope:
|
|
550
|
+
"""The change feed as a query: what changed since `since` (S13/S14/S30).
|
|
551
|
+
|
|
552
|
+
Four timestamped change types across the evidence kinds and K pages:
|
|
553
|
+
`new` (ingested after `since`), `invalidated` (retracted after it —
|
|
554
|
+
source-removal retractions land here too, since they set
|
|
555
|
+
`invalidated_at`), `capped` (a relation or observation whose validity
|
|
556
|
+
window a supersede closed — dated by the adjudication), and
|
|
557
|
+
`recompiled` (a K page rebuilt after it). `kinds` filters to a subset
|
|
558
|
+
of {relation, observation, claim, page}.
|
|
559
|
+
|
|
560
|
+
Ordered newest-first over the FULL `(at, id)` key and bounded: hitting
|
|
561
|
+
`limit` sets a truncation marker carrying an opaque `continuation`.
|
|
562
|
+
Paginating means passing that token back (keeping the same `since`) —
|
|
563
|
+
it resumes strictly before the last row seen, so a page boundary that
|
|
564
|
+
splits rows sharing one timestamp never drops the tied remainder.
|
|
565
|
+
"""
|
|
566
|
+
if limit < 1:
|
|
567
|
+
raise ValueError("limit must be at least 1")
|
|
568
|
+
cursor = _decode_feed_cursor(continuation)
|
|
569
|
+
with self._engine.connect() as connection:
|
|
570
|
+
rows = (
|
|
571
|
+
connection.execute(
|
|
572
|
+
_DELTA_FEED,
|
|
573
|
+
{
|
|
574
|
+
"deployment_id": deployment_id,
|
|
575
|
+
"since": since,
|
|
576
|
+
"kinds": list(kinds) if kinds else None,
|
|
577
|
+
"cursor_at": cursor[0] if cursor else None,
|
|
578
|
+
"cursor_id": str(cursor[1]) if cursor else None,
|
|
579
|
+
"fetch": limit + 1,
|
|
580
|
+
},
|
|
581
|
+
)
|
|
582
|
+
.mappings()
|
|
583
|
+
.all()
|
|
584
|
+
)
|
|
585
|
+
truncated = len(rows) > limit
|
|
586
|
+
kept = rows[:limit]
|
|
587
|
+
changes = tuple(
|
|
588
|
+
ChangeRecord(
|
|
589
|
+
kind=row["kind"],
|
|
590
|
+
change=row["change"],
|
|
591
|
+
id=row["id"],
|
|
592
|
+
label=row["label"],
|
|
593
|
+
at=row["at"],
|
|
594
|
+
)
|
|
595
|
+
for row in kept
|
|
596
|
+
)
|
|
597
|
+
next_cursor = (
|
|
598
|
+
_encode_feed_cursor(at=kept[-1]["at"], item_id=kept[-1]["id"])
|
|
599
|
+
if truncated and kept
|
|
600
|
+
else None
|
|
601
|
+
)
|
|
602
|
+
return Envelope(
|
|
603
|
+
grain=Grain.COMPOSITE,
|
|
604
|
+
as_of_believed_at=since,
|
|
605
|
+
changes=changes,
|
|
606
|
+
freshness=_freshness(),
|
|
607
|
+
truncation=Truncation(
|
|
608
|
+
truncated=truncated,
|
|
609
|
+
returned=len(changes),
|
|
610
|
+
estimated_total=len(changes),
|
|
611
|
+
total_is_exact=not truncated,
|
|
612
|
+
continuation=next_cursor,
|
|
613
|
+
),
|
|
614
|
+
negative=None
|
|
615
|
+
if changes
|
|
616
|
+
else Negative(
|
|
617
|
+
kind=NegativeKind.KNOWN_EMPTY,
|
|
618
|
+
explanation="nothing changed in the requested window",
|
|
619
|
+
workaround=None,
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def pages_about(
|
|
624
|
+
self,
|
|
625
|
+
*,
|
|
626
|
+
deployment_id: UUID,
|
|
627
|
+
entity_id: UUID | None = None,
|
|
628
|
+
key_kind: str | None = None,
|
|
629
|
+
key_value: str | None = None,
|
|
630
|
+
) -> Envelope:
|
|
631
|
+
"""Which K pages exist about a subject (S31/S45): the routing index,
|
|
632
|
+
read backwards.
|
|
633
|
+
|
|
634
|
+
The rule-key inverted index built to ROUTE writes doubles as the
|
|
635
|
+
reader's discovery index — mechanically, no LLM. Pass an `entity_id`
|
|
636
|
+
(shorthand for the `entity` key) or an explicit `key_kind`/`key_value`
|
|
637
|
+
(`predicate`, `community`, `doc_source`). Each page reports its
|
|
638
|
+
compile state and a `stale` flag — inputs changed but not yet
|
|
639
|
+
recompiled — so discovery never presents an out-of-date page as
|
|
640
|
+
fresh. COMPILED grain: these are pre-paid syntheses, not raw facts.
|
|
641
|
+
"""
|
|
642
|
+
if entity_id is not None:
|
|
643
|
+
key_kind, key_value = "entity", str(entity_id)
|
|
644
|
+
if key_kind is None or key_value is None:
|
|
645
|
+
raise ValueError("pages_about needs an entity_id or a key_kind+key_value")
|
|
646
|
+
with self._engine.connect() as connection:
|
|
647
|
+
rows = (
|
|
648
|
+
connection.execute(
|
|
649
|
+
_PAGES_ABOUT,
|
|
650
|
+
{
|
|
651
|
+
"deployment_id": deployment_id,
|
|
652
|
+
"key_kind": key_kind,
|
|
653
|
+
"key_value": key_value,
|
|
654
|
+
},
|
|
655
|
+
)
|
|
656
|
+
.mappings()
|
|
657
|
+
.all()
|
|
658
|
+
)
|
|
659
|
+
pages = tuple(
|
|
660
|
+
PageRef(
|
|
661
|
+
artifact_id=row["artifact_id"],
|
|
662
|
+
page_kind=row["page_kind"],
|
|
663
|
+
git_path=row["git_path"],
|
|
664
|
+
page_summary=row["page_summary"],
|
|
665
|
+
last_compiled_at=row["last_compiled_at"],
|
|
666
|
+
status=row["status"],
|
|
667
|
+
stale=row["stale"],
|
|
668
|
+
open_review_flags=row["open_review_flags"],
|
|
669
|
+
redaction_required=row["redaction_required"],
|
|
670
|
+
)
|
|
671
|
+
for row in rows
|
|
672
|
+
)
|
|
673
|
+
return Envelope(
|
|
674
|
+
grain=Grain.COMPILED,
|
|
675
|
+
pages=pages,
|
|
676
|
+
freshness=_freshness(),
|
|
677
|
+
negative=None
|
|
678
|
+
if pages
|
|
679
|
+
else Negative(
|
|
680
|
+
kind=NegativeKind.KNOWN_EMPTY,
|
|
681
|
+
explanation=f"no K pages route on {key_kind}={key_value!r}",
|
|
682
|
+
workaround="query the primitives directly; K synthesis is optional",
|
|
683
|
+
),
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
def aggregate(
|
|
687
|
+
self,
|
|
688
|
+
*,
|
|
689
|
+
deployment_id: UUID,
|
|
690
|
+
form: str,
|
|
691
|
+
subject_entity_id: UUID | None = None,
|
|
692
|
+
predicate: str | None = None,
|
|
693
|
+
entity_type: str | None = None,
|
|
694
|
+
since: datetime | None = None,
|
|
695
|
+
limit: int = 50,
|
|
696
|
+
) -> Envelope:
|
|
697
|
+
"""An enumerated aggregate — never a general GROUP BY (retrieval §9).
|
|
698
|
+
|
|
699
|
+
Each `form` is a bounded SQL shape with a predictable cost, because
|
|
700
|
+
an unbounded ad-hoc aggregation over 10⁸ rows is a denial of service
|
|
701
|
+
against the spine (the escape hatch is `scan`). The forms: `count`,
|
|
702
|
+
`group_by_predicate`, `group_by_object`, `timeline` (an entity's
|
|
703
|
+
facts by year), `delta_top_entities` (facts gained since T, bounded
|
|
704
|
+
by the delta window — S30), and `typed_absence` (entities of a type
|
|
705
|
+
with no relation of a predicate — S40, answerable because the
|
|
706
|
+
ontology types entities). A `limit`-bounded form that hits its cap
|
|
707
|
+
sets an explicit truncation marker — the bucket total is then a
|
|
708
|
+
floor, never a silent "this is all there is". An unknown form is a
|
|
709
|
+
typed `boundary`.
|
|
710
|
+
"""
|
|
711
|
+
if limit < 1:
|
|
712
|
+
raise ValueError("limit must be at least 1")
|
|
713
|
+
builder = _AGGREGATE_FORMS.get(form)
|
|
714
|
+
if builder is None:
|
|
715
|
+
return Envelope(
|
|
716
|
+
grain=Grain.FACT,
|
|
717
|
+
freshness=_freshness(),
|
|
718
|
+
negative=Negative(
|
|
719
|
+
kind=NegativeKind.BOUNDARY,
|
|
720
|
+
explanation=f"no enumerated aggregate {form!r}",
|
|
721
|
+
workaround=f"use one of: {', '.join(sorted(_AGGREGATE_FORMS))}",
|
|
722
|
+
),
|
|
723
|
+
)
|
|
724
|
+
statement, needs = builder
|
|
725
|
+
parameters = {
|
|
726
|
+
"deployment_id": deployment_id,
|
|
727
|
+
"subject_entity_id": subject_entity_id,
|
|
728
|
+
"predicate": predicate,
|
|
729
|
+
"entity_type": entity_type,
|
|
730
|
+
"since": since,
|
|
731
|
+
"fetch": limit + 1, # one extra row reveals a truncation honestly
|
|
732
|
+
}
|
|
733
|
+
for required, value in (
|
|
734
|
+
("subject_entity_id", subject_entity_id),
|
|
735
|
+
("predicate", predicate),
|
|
736
|
+
("entity_type", entity_type),
|
|
737
|
+
("since", since),
|
|
738
|
+
):
|
|
739
|
+
if required in needs and value is None:
|
|
740
|
+
raise ValueError(f"aggregate {form!r} requires {required}")
|
|
741
|
+
with self._engine.connect() as connection:
|
|
742
|
+
rows = connection.execute(statement, parameters).mappings().all()
|
|
743
|
+
bounded = form in _BOUNDED_AGGREGATE_FORMS
|
|
744
|
+
truncated = bounded and len(rows) > limit
|
|
745
|
+
buckets = tuple(
|
|
746
|
+
AggregateBucket(
|
|
747
|
+
key=None if row["key"] is None else str(row["key"]),
|
|
748
|
+
count=row["count"],
|
|
749
|
+
entity_id=row.get("entity_id"),
|
|
750
|
+
)
|
|
751
|
+
for row in (rows[:limit] if bounded else rows)
|
|
752
|
+
)
|
|
753
|
+
total = sum(bucket.count for bucket in buckets)
|
|
754
|
+
return Envelope(
|
|
755
|
+
grain=Grain.FACT,
|
|
756
|
+
as_of_believed_at=since,
|
|
757
|
+
aggregate=AggregateReport(
|
|
758
|
+
form=form,
|
|
759
|
+
buckets=buckets,
|
|
760
|
+
total=total,
|
|
761
|
+
bounded_by="delta window" if form == "delta_top_entities" else None,
|
|
762
|
+
),
|
|
763
|
+
freshness=_freshness(),
|
|
764
|
+
truncation=Truncation(
|
|
765
|
+
truncated=truncated,
|
|
766
|
+
returned=len(buckets),
|
|
767
|
+
estimated_total=len(buckets),
|
|
768
|
+
total_is_exact=not truncated,
|
|
769
|
+
)
|
|
770
|
+
if bounded
|
|
771
|
+
else None,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
def scan(
|
|
775
|
+
self, *, deployment_id: UUID, kind: str, batch_size: int = DEFAULT_SCAN_BATCH
|
|
776
|
+
) -> Iterator[ScanRow]:
|
|
777
|
+
"""The batch surface (S53): stream a filtered export, row by row.
|
|
778
|
+
|
|
779
|
+
A generator over the SEPARATE batch pool (`batch_engine`), using a
|
|
780
|
+
server-side cursor so a full export streams in bounded memory and
|
|
781
|
+
never buffers 10⁸ rows or starves the interactive pool. Same
|
|
782
|
+
zero-LLM read, same grain labels; no interactive-latency promise.
|
|
783
|
+
`kind` selects the export: `relation`, `observation`, or `claim`. An
|
|
784
|
+
unknown kind raises rather than streaming a silent empty export.
|
|
785
|
+
"""
|
|
786
|
+
if batch_size < 1:
|
|
787
|
+
raise ValueError("batch_size must be at least 1")
|
|
788
|
+
statement = _SCAN_EXPORTS.get(kind)
|
|
789
|
+
if statement is None:
|
|
790
|
+
raise ValueError(
|
|
791
|
+
f"no scan export {kind!r}; use relation, observation, or claim"
|
|
792
|
+
)
|
|
793
|
+
connection = self._batch_engine.connect().execution_options(stream_results=True)
|
|
794
|
+
try:
|
|
795
|
+
result = connection.execute(statement, {"deployment_id": deployment_id})
|
|
796
|
+
for partition in result.mappings().partitions(batch_size):
|
|
797
|
+
for row in partition:
|
|
798
|
+
yield ScanRow(
|
|
799
|
+
kind=kind, id=row["id"], label=row["label"], at=row["at"]
|
|
800
|
+
)
|
|
801
|
+
finally:
|
|
802
|
+
connection.close()
|
|
803
|
+
|
|
804
|
+
def _rerank_boundary(self, *, explanation: str, workaround: str) -> Envelope:
|
|
805
|
+
"""A rerank request the engine cannot honor, as a typed boundary."""
|
|
806
|
+
return Envelope(
|
|
807
|
+
grain=Grain.EVIDENCE,
|
|
808
|
+
freshness=_freshness(),
|
|
809
|
+
negative=Negative(
|
|
810
|
+
kind=NegativeKind.BOUNDARY,
|
|
811
|
+
explanation=explanation,
|
|
812
|
+
workaround=workaround,
|
|
813
|
+
),
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
def _enrich_facts(
|
|
817
|
+
self, *, deployment_id: UUID, facts: tuple[FactResult, ...], kind: str
|
|
818
|
+
) -> tuple[FactResult, ...]:
|
|
819
|
+
"""Attach the S23 contradiction block and the D54 support marker.
|
|
820
|
+
|
|
821
|
+
For every returned fact in a live contradiction group, the OTHER
|
|
822
|
+
live sides come back inline (bounded by the cap, with
|
|
823
|
+
group_id/returned/total/continuation) — one-sided is never a valid
|
|
824
|
+
answer. A fact under an open `support_withdrawn` review flag is
|
|
825
|
+
marked `withdrawn` (flagged, not vanished). Two bounded batch reads,
|
|
826
|
+
never one-per-fact.
|
|
827
|
+
"""
|
|
828
|
+
if not facts:
|
|
829
|
+
return facts
|
|
830
|
+
groups = [
|
|
831
|
+
fact.contradiction_group
|
|
832
|
+
for fact in facts
|
|
833
|
+
if fact.contradiction_group is not None
|
|
834
|
+
]
|
|
835
|
+
members_by_group: dict[UUID, list[dict[str, object]]] = {}
|
|
836
|
+
withdrawn: set[UUID] = set()
|
|
837
|
+
with self._engine.connect() as connection:
|
|
838
|
+
if groups:
|
|
839
|
+
for row in (
|
|
840
|
+
connection.execute(
|
|
841
|
+
_CONTRADICTION_MEMBERS[kind],
|
|
842
|
+
{"deployment_id": deployment_id, "groups": groups},
|
|
843
|
+
)
|
|
844
|
+
.mappings()
|
|
845
|
+
.all()
|
|
846
|
+
):
|
|
847
|
+
members_by_group.setdefault(row["contradiction_group"], []).append(
|
|
848
|
+
dict(row)
|
|
849
|
+
)
|
|
850
|
+
withdrawn = {
|
|
851
|
+
row["fact_id"]
|
|
852
|
+
for row in connection.execute(
|
|
853
|
+
_OPEN_SUPPORT_FLAGS,
|
|
854
|
+
{
|
|
855
|
+
"deployment_id": deployment_id,
|
|
856
|
+
"fact_ids": [str(fact.fact_id) for fact in facts],
|
|
857
|
+
},
|
|
858
|
+
)
|
|
859
|
+
.mappings()
|
|
860
|
+
.all()
|
|
861
|
+
}
|
|
862
|
+
return tuple(
|
|
863
|
+
self._enrich_one(
|
|
864
|
+
fact=fact, members_by_group=members_by_group, withdrawn=withdrawn
|
|
865
|
+
)
|
|
866
|
+
for fact in facts
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
def _enrich_one(
|
|
870
|
+
self,
|
|
871
|
+
*,
|
|
872
|
+
fact: FactResult,
|
|
873
|
+
members_by_group: dict[UUID, list[dict[str, object]]],
|
|
874
|
+
withdrawn: set[UUID],
|
|
875
|
+
) -> FactResult:
|
|
876
|
+
"""One fact, with its contradiction block and support marker resolved."""
|
|
877
|
+
update: dict[str, object] = {}
|
|
878
|
+
if fact.fact_id in withdrawn:
|
|
879
|
+
update["support"] = FactSupport.WITHDRAWN
|
|
880
|
+
if fact.contradiction_group is not None:
|
|
881
|
+
others = [
|
|
882
|
+
member
|
|
883
|
+
for member in members_by_group.get(fact.contradiction_group, [])
|
|
884
|
+
if member["fact_id"] != fact.fact_id
|
|
885
|
+
]
|
|
886
|
+
returned = others[:CONTRADICTION_COMEMBER_CAP]
|
|
887
|
+
update["contradiction"] = Contradiction(
|
|
888
|
+
group_id=fact.contradiction_group,
|
|
889
|
+
co_members=tuple(_co_member(member) for member in returned),
|
|
890
|
+
returned=len(returned),
|
|
891
|
+
total=len(others),
|
|
892
|
+
continuation=(
|
|
893
|
+
str(returned[-1]["fact_id"])
|
|
894
|
+
if len(returned) < len(others)
|
|
895
|
+
else None
|
|
896
|
+
),
|
|
897
|
+
)
|
|
898
|
+
return fact.model_copy(update=update) if update else fact
|
|
899
|
+
|
|
900
|
+
def _confirm_claims(
|
|
901
|
+
self, *, deployment_id: UUID, claim_ids: tuple[UUID, ...]
|
|
902
|
+
) -> tuple[tuple[EvidenceResult, ...], int]:
|
|
903
|
+
"""The D48 confirmation hop for claim nominations, order-preserving."""
|
|
904
|
+
if not claim_ids:
|
|
905
|
+
return (), 0
|
|
906
|
+
rows: list[RowMapping] = []
|
|
907
|
+
# Multiple chunks are one answer, so they must observe one database
|
|
908
|
+
# snapshot rather than mixing currency states across round trips.
|
|
909
|
+
with self._engine.connect().execution_options(
|
|
910
|
+
isolation_level="REPEATABLE READ"
|
|
911
|
+
) as connection:
|
|
912
|
+
for batch in batched(claim_ids, INTERACTIVE_HYDRATION_BATCH_SIZE):
|
|
913
|
+
rows.extend(
|
|
914
|
+
connection.execute(
|
|
915
|
+
_CONFIRM_CLAIMS,
|
|
916
|
+
{"deployment_id": deployment_id, "claim_ids": list(batch)},
|
|
917
|
+
)
|
|
918
|
+
.mappings()
|
|
919
|
+
.all()
|
|
920
|
+
)
|
|
921
|
+
confirmed = {row["claim_id"]: row for row in rows}
|
|
922
|
+
results = tuple(
|
|
923
|
+
EvidenceResult.model_validate(dict(confirmed[claim_id]))
|
|
924
|
+
for claim_id in claim_ids
|
|
925
|
+
if claim_id in confirmed
|
|
926
|
+
)
|
|
927
|
+
return results, len(claim_ids) - len(results)
|
|
928
|
+
|
|
929
|
+
def _confirm_observations(
|
|
930
|
+
self,
|
|
931
|
+
*,
|
|
932
|
+
deployment_id: UUID,
|
|
933
|
+
entity_id: UUID,
|
|
934
|
+
observation_ids: tuple[UUID, ...],
|
|
935
|
+
as_of: datetime,
|
|
936
|
+
) -> tuple[tuple[dict[str, object], ...], int]:
|
|
937
|
+
"""The D48 confirmation hop for observation nominations."""
|
|
938
|
+
if not observation_ids:
|
|
939
|
+
return (), 0
|
|
940
|
+
rows: list[RowMapping] = []
|
|
941
|
+
with self._engine.connect().execution_options(
|
|
942
|
+
isolation_level="REPEATABLE READ"
|
|
943
|
+
) as connection:
|
|
944
|
+
for batch in batched(observation_ids, INTERACTIVE_HYDRATION_BATCH_SIZE):
|
|
945
|
+
rows.extend(
|
|
946
|
+
connection.execute(
|
|
947
|
+
_CONFIRM_OBSERVATIONS,
|
|
948
|
+
{
|
|
949
|
+
"deployment_id": deployment_id,
|
|
950
|
+
"entity_id": entity_id,
|
|
951
|
+
"observation_ids": list(batch),
|
|
952
|
+
"as_of": as_of,
|
|
953
|
+
},
|
|
954
|
+
)
|
|
955
|
+
.mappings()
|
|
956
|
+
.all()
|
|
957
|
+
)
|
|
958
|
+
confirmed = {row["fact_id"]: dict(row) for row in rows}
|
|
959
|
+
results = tuple(
|
|
960
|
+
confirmed[observation_id]
|
|
961
|
+
for observation_id in observation_ids
|
|
962
|
+
if observation_id in confirmed
|
|
963
|
+
)
|
|
964
|
+
return results, len(observation_ids) - len(results)
|
|
965
|
+
|
|
966
|
+
def _embed(self, *, query: str) -> tuple[float, ...]:
|
|
967
|
+
"""One query-string embedding through the configured port (D63)."""
|
|
968
|
+
response = self._model_provider.embed(
|
|
969
|
+
request=EmbeddingRequest(model=self._embedding_model, texts=(query,))
|
|
970
|
+
)
|
|
971
|
+
return response.vectors[0]
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _freshness() -> Freshness:
|
|
975
|
+
"""The skeleton's freshness stamps: PG is live; P1 is written inline.
|
|
976
|
+
|
|
977
|
+
The `believed_at` horizons are null (unbounded): Postgres holds full
|
|
978
|
+
belief history, and under D69 the hot P2 view keeps every relation whose
|
|
979
|
+
endpoints stay emitted. A channel that grows a real finite horizon fills
|
|
980
|
+
these in, and `believed_at_boundary` turns a query before it into a typed
|
|
981
|
+
boundary.
|
|
982
|
+
"""
|
|
983
|
+
return Freshness(pg_live_ts=datetime.now(tz=UTC))
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def believed_at_boundary(
|
|
987
|
+
*, believed_at: datetime | None, horizon: datetime | None
|
|
988
|
+
) -> Negative | None:
|
|
989
|
+
"""A typed boundary when a `believed_at` query predates a channel horizon.
|
|
990
|
+
|
|
991
|
+
Belief history is not infinite on every channel: if a channel reports a
|
|
992
|
+
finite `believed_at` horizon and the caller asks for an instant before
|
|
993
|
+
it, that is a stated capability limit (retrieval §3) — a `boundary` that
|
|
994
|
+
names the fallback, never a silently truncated answer. Null horizon
|
|
995
|
+
(unbounded) never triggers it.
|
|
996
|
+
"""
|
|
997
|
+
if believed_at is None or horizon is None or believed_at >= horizon:
|
|
998
|
+
return None
|
|
999
|
+
return Negative(
|
|
1000
|
+
kind=NegativeKind.BOUNDARY,
|
|
1001
|
+
explanation=(
|
|
1002
|
+
f"believed_at {believed_at.isoformat()} is before this channel's"
|
|
1003
|
+
f" retention horizon {horizon.isoformat()}"
|
|
1004
|
+
),
|
|
1005
|
+
workaround="query a later instant, or read Postgres belief history",
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _fact_result(*, row, kind: str) -> FactResult: # noqa: ANN001
|
|
1010
|
+
"""Build one fact-grain record from a hydrated spine row."""
|
|
1011
|
+
mapping = dict(row)
|
|
1012
|
+
return FactResult(
|
|
1013
|
+
fact_id=row["fact_id"],
|
|
1014
|
+
kind=kind,
|
|
1015
|
+
label=row["label"],
|
|
1016
|
+
evidence_count=row["evidence_count"],
|
|
1017
|
+
contradiction_group=mapping.get("contradiction_group"),
|
|
1018
|
+
validity=Validity(
|
|
1019
|
+
valid_from=row["valid_from"],
|
|
1020
|
+
valid_until=row["valid_until"],
|
|
1021
|
+
ingested_at=row["ingested_at"],
|
|
1022
|
+
invalidated_at=row["invalidated_at"],
|
|
1023
|
+
),
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _co_member(row: dict[str, object]) -> CoMember:
|
|
1028
|
+
"""Build one contradiction co-member record from a live spine row."""
|
|
1029
|
+
return CoMember(
|
|
1030
|
+
fact_id=row["fact_id"], # type: ignore[arg-type]
|
|
1031
|
+
label=row["label"], # type: ignore[arg-type]
|
|
1032
|
+
evidence_count=row["evidence_count"], # type: ignore[arg-type]
|
|
1033
|
+
validity=Validity(
|
|
1034
|
+
valid_from=row["valid_from"], # type: ignore[arg-type]
|
|
1035
|
+
valid_until=row["valid_until"], # type: ignore[arg-type]
|
|
1036
|
+
ingested_at=row["ingested_at"], # type: ignore[arg-type]
|
|
1037
|
+
invalidated_at=row["invalidated_at"], # type: ignore[arg-type]
|
|
1038
|
+
),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
_RESOLVE_T0 = text(
|
|
1043
|
+
"""
|
|
1044
|
+
WITH RECURSIVE matched AS (
|
|
1045
|
+
SELECT entities.entity_id, entities.canonical_name, entities.type,
|
|
1046
|
+
entities.status, entities.merged_into
|
|
1047
|
+
FROM aliases
|
|
1048
|
+
JOIN entities ON entities.deployment_id = aliases.deployment_id
|
|
1049
|
+
AND entities.entity_id = aliases.entity_id
|
|
1050
|
+
WHERE aliases.deployment_id = :deployment_id
|
|
1051
|
+
AND aliases.normalized_lemma = :lemma
|
|
1052
|
+
UNION
|
|
1053
|
+
-- follow merge redirects to the survivor (S60: resolve returns
|
|
1054
|
+
-- CURRENT identities; the redirect chain is walked, never dead-ended)
|
|
1055
|
+
SELECT survivor.entity_id, survivor.canonical_name, survivor.type,
|
|
1056
|
+
survivor.status, survivor.merged_into
|
|
1057
|
+
FROM matched
|
|
1058
|
+
JOIN entities survivor ON survivor.deployment_id = :deployment_id
|
|
1059
|
+
AND survivor.entity_id = matched.merged_into
|
|
1060
|
+
WHERE matched.status = 'merged'
|
|
1061
|
+
)
|
|
1062
|
+
SELECT DISTINCT entity_id, canonical_name, type
|
|
1063
|
+
FROM matched
|
|
1064
|
+
WHERE status = 'active'
|
|
1065
|
+
AND (CAST(:entity_type AS text) IS NULL OR type = :entity_type)
|
|
1066
|
+
"""
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
_RESOLVE_CONTEXT_HITS = text(
|
|
1070
|
+
"""
|
|
1071
|
+
SELECT candidate_id, count(DISTINCT context_entity_id) AS context_hits
|
|
1072
|
+
FROM (
|
|
1073
|
+
SELECT subject_entity_id AS candidate_id,
|
|
1074
|
+
object_entity_id AS context_entity_id
|
|
1075
|
+
FROM relations
|
|
1076
|
+
WHERE deployment_id = :deployment_id
|
|
1077
|
+
AND subject_entity_id = ANY(:candidate_ids)
|
|
1078
|
+
AND object_entity_id = ANY(:context_entity_ids)
|
|
1079
|
+
AND invalidated_at IS NULL
|
|
1080
|
+
AND (valid_from IS NULL OR valid_from <= now())
|
|
1081
|
+
AND (valid_until IS NULL OR valid_until > now())
|
|
1082
|
+
UNION ALL
|
|
1083
|
+
SELECT object_entity_id AS candidate_id,
|
|
1084
|
+
subject_entity_id AS context_entity_id
|
|
1085
|
+
FROM relations
|
|
1086
|
+
WHERE deployment_id = :deployment_id
|
|
1087
|
+
AND object_entity_id = ANY(:candidate_ids)
|
|
1088
|
+
AND subject_entity_id = ANY(:context_entity_ids)
|
|
1089
|
+
AND invalidated_at IS NULL
|
|
1090
|
+
AND (valid_from IS NULL OR valid_from <= now())
|
|
1091
|
+
AND (valid_until IS NULL OR valid_until > now())
|
|
1092
|
+
) adjacent
|
|
1093
|
+
GROUP BY candidate_id
|
|
1094
|
+
"""
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
_LOOKUP_RELATIONS = text(
|
|
1098
|
+
"""
|
|
1099
|
+
SELECT relation_id AS fact_id,
|
|
1100
|
+
coalesce(fact_label, predicate) AS label,
|
|
1101
|
+
evidence_count, valid_from, valid_until, ingested_at, invalidated_at,
|
|
1102
|
+
contradiction_group
|
|
1103
|
+
FROM relations
|
|
1104
|
+
WHERE deployment_id = :deployment_id
|
|
1105
|
+
AND invalidated_at IS NULL
|
|
1106
|
+
AND (valid_from IS NULL OR valid_from <= :as_of)
|
|
1107
|
+
AND (valid_until IS NULL OR valid_until > :as_of)
|
|
1108
|
+
AND (CAST(:subject_entity_id AS uuid) IS NULL
|
|
1109
|
+
OR subject_entity_id = :subject_entity_id)
|
|
1110
|
+
AND (CAST(:predicate AS text) IS NULL OR predicate = :predicate)
|
|
1111
|
+
AND (CAST(:object_entity_id AS uuid) IS NULL
|
|
1112
|
+
OR object_entity_id = :object_entity_id)
|
|
1113
|
+
ORDER BY evidence_count DESC, ingested_at
|
|
1114
|
+
"""
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
_LOOKUP_OBSERVATIONS = text(
|
|
1118
|
+
"""
|
|
1119
|
+
SELECT observation_id AS fact_id, statement AS label,
|
|
1120
|
+
evidence_count, valid_from, valid_until, ingested_at, invalidated_at,
|
|
1121
|
+
contradiction_group
|
|
1122
|
+
FROM observations
|
|
1123
|
+
WHERE deployment_id = :deployment_id
|
|
1124
|
+
AND subject_entity_id = :entity_id
|
|
1125
|
+
AND invalidated_at IS NULL
|
|
1126
|
+
AND (valid_from IS NULL OR valid_from <= :as_of)
|
|
1127
|
+
AND (valid_until IS NULL OR valid_until > :as_of)
|
|
1128
|
+
ORDER BY evidence_count DESC, ingested_at
|
|
1129
|
+
"""
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
_CONFIRM_OBSERVATIONS = text(
|
|
1133
|
+
"""
|
|
1134
|
+
SELECT observation_id AS fact_id, statement AS label,
|
|
1135
|
+
evidence_count, valid_from, valid_until, ingested_at, invalidated_at,
|
|
1136
|
+
contradiction_group
|
|
1137
|
+
FROM observations
|
|
1138
|
+
WHERE deployment_id = :deployment_id
|
|
1139
|
+
AND subject_entity_id = :entity_id
|
|
1140
|
+
AND observation_id = ANY(:observation_ids)
|
|
1141
|
+
AND invalidated_at IS NULL
|
|
1142
|
+
AND (valid_from IS NULL OR valid_from <= :as_of)
|
|
1143
|
+
AND (valid_until IS NULL OR valid_until > :as_of)
|
|
1144
|
+
"""
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
_CONFIRM_CLAIMS = text(
|
|
1148
|
+
"""
|
|
1149
|
+
SELECT claim_id, doc_id, chunk_id, claim_text, source_span,
|
|
1150
|
+
char_start, char_end, is_attributed, is_current_testimony
|
|
1151
|
+
FROM claims
|
|
1152
|
+
WHERE deployment_id = :deployment_id
|
|
1153
|
+
AND claim_id = ANY(:claim_ids)
|
|
1154
|
+
AND is_current_testimony
|
|
1155
|
+
"""
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
_HYDRATE_RELATION = text(
|
|
1159
|
+
"""
|
|
1160
|
+
SELECT relation_id AS fact_id,
|
|
1161
|
+
coalesce(fact_label, predicate) AS label,
|
|
1162
|
+
evidence_count, valid_from, valid_until, ingested_at, invalidated_at,
|
|
1163
|
+
contradiction_group
|
|
1164
|
+
FROM relations
|
|
1165
|
+
WHERE deployment_id = :deployment_id AND relation_id = :relation_id
|
|
1166
|
+
"""
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
_HYDRATE_EVIDENCE_CLAIMS = text(
|
|
1170
|
+
"""
|
|
1171
|
+
SELECT c.claim_id, c.doc_id, c.chunk_id, c.claim_text, c.source_span,
|
|
1172
|
+
c.char_start, c.char_end, c.is_attributed, c.is_current_testimony
|
|
1173
|
+
FROM relation_evidence e
|
|
1174
|
+
JOIN claims c ON c.claim_id = e.claim_id
|
|
1175
|
+
WHERE e.relation_id = :relation_id AND e.stance = 'supports'
|
|
1176
|
+
ORDER BY c.ingested_at, c.claim_id
|
|
1177
|
+
"""
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
_HYDRATE_SOURCES = text(
|
|
1181
|
+
"""
|
|
1182
|
+
SELECT DISTINCT d.doc_id, d.title, d.source_kind, r.markdown_uri
|
|
1183
|
+
FROM relation_evidence e
|
|
1184
|
+
JOIN claims c ON c.claim_id = e.claim_id
|
|
1185
|
+
JOIN chunks ch ON ch.chunk_id = c.chunk_id
|
|
1186
|
+
JOIN documents d ON d.doc_id = e.doc_id
|
|
1187
|
+
LEFT JOIN document_representations r
|
|
1188
|
+
ON r.representation_id = ch.representation_id
|
|
1189
|
+
WHERE e.relation_id = :relation_id
|
|
1190
|
+
AND e.stance = 'supports'
|
|
1191
|
+
"""
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
_RELATION_TRANSCRIPT = text(
|
|
1195
|
+
"""
|
|
1196
|
+
-- related_id is always the OTHER relation in the pair, whichever side of
|
|
1197
|
+
-- the adjudication the subject sits on (never the subject itself)
|
|
1198
|
+
SELECT 'relation' AS subject_kind,
|
|
1199
|
+
outcome::text AS outcome, method::text AS method, confidence,
|
|
1200
|
+
CASE WHEN relation_id = :subject_id THEN related_relation_id
|
|
1201
|
+
ELSE relation_id END AS related_id,
|
|
1202
|
+
decided_by::text AS decided_by, decided_at, features
|
|
1203
|
+
FROM relation_adjudications
|
|
1204
|
+
WHERE deployment_id = :deployment_id
|
|
1205
|
+
AND (relation_id = :subject_id OR related_relation_id = :subject_id)
|
|
1206
|
+
ORDER BY decided_at, adjudication_id
|
|
1207
|
+
"""
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
_OBSERVATION_TRANSCRIPT = text(
|
|
1211
|
+
"""
|
|
1212
|
+
SELECT 'observation' AS subject_kind,
|
|
1213
|
+
outcome::text AS outcome, method::text AS method, confidence,
|
|
1214
|
+
CASE WHEN observation_id = :subject_id THEN related_observation_id
|
|
1215
|
+
ELSE observation_id END AS related_id,
|
|
1216
|
+
decided_by::text AS decided_by, decided_at, features
|
|
1217
|
+
FROM observation_adjudications
|
|
1218
|
+
WHERE deployment_id = :deployment_id
|
|
1219
|
+
AND (observation_id = :subject_id OR related_observation_id = :subject_id)
|
|
1220
|
+
ORDER BY decided_at, adjudication_id
|
|
1221
|
+
"""
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
_ENTITY_TRANSCRIPT = text(
|
|
1225
|
+
"""
|
|
1226
|
+
-- an entity's decision history braids two append-only logs: how each of
|
|
1227
|
+
-- its mentions resolved (resolution_decisions) and every merge it took
|
|
1228
|
+
-- part in (merge_events), newest-last across both. related_id is the
|
|
1229
|
+
-- COUNTERPART entity of a merge (never the subject); a reversed merge is
|
|
1230
|
+
-- an unmerge.
|
|
1231
|
+
SELECT 'entity' AS subject_kind,
|
|
1232
|
+
CASE WHEN is_new_entity THEN 'new_entity' ELSE 'linked' END AS outcome,
|
|
1233
|
+
method::text AS method, confidence,
|
|
1234
|
+
mention_id AS related_id, decided_by::text AS decided_by,
|
|
1235
|
+
decided_at, features
|
|
1236
|
+
FROM resolution_decisions
|
|
1237
|
+
WHERE deployment_id = :deployment_id AND entity_id = :subject_id
|
|
1238
|
+
UNION ALL
|
|
1239
|
+
SELECT 'entity' AS subject_kind,
|
|
1240
|
+
CASE WHEN reversed_by IS NOT NULL THEN 'unmerge' ELSE 'merge' END
|
|
1241
|
+
AS outcome,
|
|
1242
|
+
'merge_event' AS method, NULL::real AS confidence,
|
|
1243
|
+
CASE WHEN survivor_id = :subject_id THEN absorbed_id
|
|
1244
|
+
ELSE survivor_id END AS related_id,
|
|
1245
|
+
decided_by::text AS decided_by, decided_at, evidence AS features
|
|
1246
|
+
FROM merge_events
|
|
1247
|
+
WHERE deployment_id = :deployment_id
|
|
1248
|
+
AND (survivor_id = :subject_id OR absorbed_id = :subject_id)
|
|
1249
|
+
ORDER BY decided_at
|
|
1250
|
+
"""
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
_KPAGE_TRANSCRIPT = text(
|
|
1254
|
+
"""
|
|
1255
|
+
-- a K page's provenance is its compile history: each recompilation, what
|
|
1256
|
+
-- it cited, and the writer that produced it (S35)
|
|
1257
|
+
SELECT 'k_page' AS subject_kind,
|
|
1258
|
+
'compiled' AS outcome, writer_version AS method,
|
|
1259
|
+
NULL::real AS confidence, artifact_id AS related_id,
|
|
1260
|
+
'writer'::text AS decided_by, compiled_at AS decided_at,
|
|
1261
|
+
jsonb_build_object('cited', cited_count, 'uncited', uncited_count,
|
|
1262
|
+
'evidence_added', evidence_added,
|
|
1263
|
+
'evidence_removed', evidence_removed) AS features
|
|
1264
|
+
FROM knowledge_compilations
|
|
1265
|
+
WHERE deployment_id = :deployment_id AND artifact_id = :subject_id
|
|
1266
|
+
ORDER BY compiled_at, compilation_id
|
|
1267
|
+
"""
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
_TRANSCRIPT_BY_KIND = {
|
|
1271
|
+
"relation": _RELATION_TRANSCRIPT,
|
|
1272
|
+
"observation": _OBSERVATION_TRANSCRIPT,
|
|
1273
|
+
"entity": _ENTITY_TRANSCRIPT,
|
|
1274
|
+
"k_page": _KPAGE_TRANSCRIPT,
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
_DELTA_FEED = text(
|
|
1279
|
+
"""
|
|
1280
|
+
-- the change feed: one timestamped row per change, unioned across the
|
|
1281
|
+
-- evidence kinds and K pages, filtered by :since and an optional :kinds
|
|
1282
|
+
-- subset. Every branch dates its change on a real column, so a follow-up
|
|
1283
|
+
-- delta resumes deterministically from the oldest `at` returned.
|
|
1284
|
+
WITH feed AS (
|
|
1285
|
+
SELECT 'relation' AS kind, 'new' AS change, relation_id AS id,
|
|
1286
|
+
coalesce(fact_label, predicate) AS label, ingested_at AS at
|
|
1287
|
+
FROM relations
|
|
1288
|
+
WHERE deployment_id = :deployment_id AND ingested_at > :since
|
|
1289
|
+
UNION ALL
|
|
1290
|
+
SELECT 'relation', 'invalidated', relation_id,
|
|
1291
|
+
coalesce(fact_label, predicate), invalidated_at
|
|
1292
|
+
FROM relations
|
|
1293
|
+
WHERE deployment_id = :deployment_id AND invalidated_at > :since
|
|
1294
|
+
UNION ALL
|
|
1295
|
+
-- a supersede caps the OLD relation's window (ra.relation_id), dated
|
|
1296
|
+
-- by the adjudication that closed it
|
|
1297
|
+
SELECT 'relation', 'capped', r.relation_id,
|
|
1298
|
+
coalesce(r.fact_label, r.predicate), ra.decided_at
|
|
1299
|
+
FROM relation_adjudications ra
|
|
1300
|
+
JOIN relations r ON r.deployment_id = ra.deployment_id
|
|
1301
|
+
AND r.relation_id = ra.relation_id
|
|
1302
|
+
WHERE ra.deployment_id = :deployment_id
|
|
1303
|
+
AND ra.outcome = 'supersede' AND ra.decided_at > :since
|
|
1304
|
+
UNION ALL
|
|
1305
|
+
SELECT 'observation', 'new', observation_id, statement, ingested_at
|
|
1306
|
+
FROM observations
|
|
1307
|
+
WHERE deployment_id = :deployment_id AND ingested_at > :since
|
|
1308
|
+
UNION ALL
|
|
1309
|
+
SELECT 'observation', 'invalidated', observation_id, statement,
|
|
1310
|
+
invalidated_at
|
|
1311
|
+
FROM observations
|
|
1312
|
+
WHERE deployment_id = :deployment_id AND invalidated_at > :since
|
|
1313
|
+
UNION ALL
|
|
1314
|
+
-- an observation supersede caps the OLD observation's window, dated
|
|
1315
|
+
-- by the adjudication (symmetric with the relation cap above)
|
|
1316
|
+
SELECT 'observation', 'capped', o.observation_id, o.statement,
|
|
1317
|
+
oa.decided_at
|
|
1318
|
+
FROM observation_adjudications oa
|
|
1319
|
+
JOIN observations o ON o.deployment_id = oa.deployment_id
|
|
1320
|
+
AND o.observation_id = oa.observation_id
|
|
1321
|
+
WHERE oa.deployment_id = :deployment_id
|
|
1322
|
+
AND oa.outcome = 'supersede' AND oa.decided_at > :since
|
|
1323
|
+
UNION ALL
|
|
1324
|
+
SELECT 'claim', 'new', claim_id, left(claim_text, 80), ingested_at
|
|
1325
|
+
FROM claims
|
|
1326
|
+
WHERE deployment_id = :deployment_id AND ingested_at > :since
|
|
1327
|
+
UNION ALL
|
|
1328
|
+
SELECT 'page', 'recompiled', artifact_id, NULL, compiled_at
|
|
1329
|
+
FROM knowledge_compilations
|
|
1330
|
+
WHERE deployment_id = :deployment_id AND compiled_at > :since
|
|
1331
|
+
)
|
|
1332
|
+
SELECT kind, change, id, label, at
|
|
1333
|
+
FROM feed
|
|
1334
|
+
WHERE (CAST(:kinds AS text[]) IS NULL OR kind = ANY(:kinds))
|
|
1335
|
+
-- resume strictly before the cursor over the FULL (at, id) order, so a
|
|
1336
|
+
-- page boundary that splits rows sharing a timestamp never drops the
|
|
1337
|
+
-- tied remainder
|
|
1338
|
+
AND (
|
|
1339
|
+
CAST(:cursor_at AS timestamptz) IS NULL
|
|
1340
|
+
OR at < :cursor_at
|
|
1341
|
+
OR (at = :cursor_at AND id < CAST(:cursor_id AS uuid))
|
|
1342
|
+
)
|
|
1343
|
+
ORDER BY at DESC, id DESC
|
|
1344
|
+
LIMIT :fetch
|
|
1345
|
+
"""
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
_PAGES_ABOUT = text(
|
|
1349
|
+
"""
|
|
1350
|
+
-- the rule-key inverted index read backwards: which artifacts route on
|
|
1351
|
+
-- (:key_kind, :key_value). One row per artifact (a page may hold several
|
|
1352
|
+
-- matching rules), each carrying its compile state and a stale flag —
|
|
1353
|
+
-- a page whose refresh is still queued has not caught up to its inputs.
|
|
1354
|
+
SELECT * FROM (
|
|
1355
|
+
SELECT DISTINCT ON (a.artifact_id)
|
|
1356
|
+
a.artifact_id, a.page_kind::text AS page_kind, a.git_path,
|
|
1357
|
+
a.page_summary, a.last_compiled_at, a.status::text AS status,
|
|
1358
|
+
(a.page_kind = 'compiled' AND (
|
|
1359
|
+
a.status::text = 'stale' OR EXISTS (
|
|
1360
|
+
SELECT 1 FROM knowledge_refresh_queue q
|
|
1361
|
+
WHERE q.deployment_id = a.deployment_id
|
|
1362
|
+
AND q.artifact_id = a.artifact_id
|
|
1363
|
+
AND q.processed_at IS NULL
|
|
1364
|
+
))) AS stale,
|
|
1365
|
+
CASE WHEN a.page_kind = 'authored' THEN (
|
|
1366
|
+
SELECT count(*) FROM knowledge_refresh_queue q
|
|
1367
|
+
WHERE q.deployment_id = a.deployment_id
|
|
1368
|
+
AND q.artifact_id = a.artifact_id
|
|
1369
|
+
AND q.trigger = 'authored_review'
|
|
1370
|
+
AND q.processed_at IS NULL
|
|
1371
|
+
) ELSE 0 END AS open_review_flags,
|
|
1372
|
+
CASE WHEN a.page_kind = 'authored' THEN COALESCE((
|
|
1373
|
+
SELECT bool_or(
|
|
1374
|
+
COALESCE((q.payload ->> 'redaction_required')::boolean, false)
|
|
1375
|
+
)
|
|
1376
|
+
FROM knowledge_refresh_queue q
|
|
1377
|
+
WHERE q.deployment_id = a.deployment_id
|
|
1378
|
+
AND q.artifact_id = a.artifact_id
|
|
1379
|
+
AND q.trigger = 'authored_review'
|
|
1380
|
+
AND q.processed_at IS NULL
|
|
1381
|
+
), false) ELSE false END AS redaction_required
|
|
1382
|
+
FROM knowledge_rule_keys rk
|
|
1383
|
+
JOIN knowledge_page_rules pr ON pr.deployment_id = rk.deployment_id
|
|
1384
|
+
AND pr.rule_id = rk.rule_id
|
|
1385
|
+
JOIN knowledge_artifacts a ON a.deployment_id = pr.deployment_id
|
|
1386
|
+
AND a.artifact_id = pr.artifact_id
|
|
1387
|
+
WHERE rk.deployment_id = :deployment_id
|
|
1388
|
+
AND rk.key_kind = CAST(:key_kind AS rule_key_kind)
|
|
1389
|
+
AND rk.key_value = :key_value
|
|
1390
|
+
AND pr.status = 'active' -- a deprecated rule no longer routes
|
|
1391
|
+
AND a.status::text <> 'tombstoned'
|
|
1392
|
+
ORDER BY a.artifact_id
|
|
1393
|
+
) page
|
|
1394
|
+
ORDER BY page.last_compiled_at DESC NULLS LAST, page.artifact_id
|
|
1395
|
+
"""
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
_AGG_COUNT = text(
|
|
1399
|
+
"""
|
|
1400
|
+
SELECT NULL::text AS key, count(*) AS count, NULL::uuid AS entity_id
|
|
1401
|
+
FROM relations
|
|
1402
|
+
WHERE deployment_id = :deployment_id AND invalidated_at IS NULL
|
|
1403
|
+
AND (CAST(:subject_entity_id AS uuid) IS NULL
|
|
1404
|
+
OR subject_entity_id = :subject_entity_id)
|
|
1405
|
+
AND (CAST(:predicate AS text) IS NULL OR predicate = :predicate)
|
|
1406
|
+
"""
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
_AGG_GROUP_BY_PREDICATE = text(
|
|
1410
|
+
"""
|
|
1411
|
+
SELECT predicate AS key, count(*) AS count, NULL::uuid AS entity_id
|
|
1412
|
+
FROM relations
|
|
1413
|
+
WHERE deployment_id = :deployment_id AND invalidated_at IS NULL
|
|
1414
|
+
AND subject_entity_id = :subject_entity_id
|
|
1415
|
+
GROUP BY predicate
|
|
1416
|
+
ORDER BY count DESC, predicate
|
|
1417
|
+
LIMIT :fetch
|
|
1418
|
+
"""
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
_AGG_GROUP_BY_OBJECT = text(
|
|
1422
|
+
"""
|
|
1423
|
+
SELECT e.canonical_name AS key, count(*) AS count,
|
|
1424
|
+
r.object_entity_id AS entity_id
|
|
1425
|
+
FROM relations r
|
|
1426
|
+
JOIN entities e ON e.deployment_id = r.deployment_id
|
|
1427
|
+
AND e.entity_id = r.object_entity_id
|
|
1428
|
+
WHERE r.deployment_id = :deployment_id AND r.invalidated_at IS NULL
|
|
1429
|
+
AND r.subject_entity_id = :subject_entity_id
|
|
1430
|
+
AND (CAST(:predicate AS text) IS NULL OR r.predicate = :predicate)
|
|
1431
|
+
GROUP BY e.canonical_name, r.object_entity_id
|
|
1432
|
+
ORDER BY count DESC, e.canonical_name
|
|
1433
|
+
LIMIT :fetch
|
|
1434
|
+
"""
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
_AGG_TIMELINE = text(
|
|
1438
|
+
"""
|
|
1439
|
+
-- an entity's facts by year — relations it is either end of AND the
|
|
1440
|
+
-- observations about it, so the timeline is the whole fact evolution,
|
|
1441
|
+
-- not just relations
|
|
1442
|
+
SELECT to_char(date_trunc('year', ts), 'YYYY') AS key,
|
|
1443
|
+
count(*) AS count, NULL::uuid AS entity_id
|
|
1444
|
+
FROM (
|
|
1445
|
+
SELECT coalesce(valid_from, ingested_at) AS ts
|
|
1446
|
+
FROM relations
|
|
1447
|
+
WHERE deployment_id = :deployment_id AND invalidated_at IS NULL
|
|
1448
|
+
AND (subject_entity_id = :subject_entity_id
|
|
1449
|
+
OR object_entity_id = :subject_entity_id)
|
|
1450
|
+
UNION ALL
|
|
1451
|
+
SELECT coalesce(valid_from, ingested_at) AS ts
|
|
1452
|
+
FROM observations
|
|
1453
|
+
WHERE deployment_id = :deployment_id AND invalidated_at IS NULL
|
|
1454
|
+
AND subject_entity_id = :subject_entity_id
|
|
1455
|
+
) facts
|
|
1456
|
+
GROUP BY 1
|
|
1457
|
+
ORDER BY 1
|
|
1458
|
+
"""
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
_AGG_DELTA_TOP_ENTITIES = text(
|
|
1462
|
+
"""
|
|
1463
|
+
-- facts gained since T, grouped by the subject entity, bounded by the
|
|
1464
|
+
-- delta window (S30): a leaderboard of what moved, over relations AND
|
|
1465
|
+
-- observations, not a full-history scan
|
|
1466
|
+
SELECT e.canonical_name AS key, sum(gained.cnt) AS count,
|
|
1467
|
+
gained.entity_id AS entity_id
|
|
1468
|
+
FROM (
|
|
1469
|
+
SELECT subject_entity_id AS entity_id, count(*) AS cnt
|
|
1470
|
+
FROM relations
|
|
1471
|
+
WHERE deployment_id = :deployment_id AND ingested_at > :since
|
|
1472
|
+
GROUP BY subject_entity_id
|
|
1473
|
+
UNION ALL
|
|
1474
|
+
SELECT subject_entity_id AS entity_id, count(*) AS cnt
|
|
1475
|
+
FROM observations
|
|
1476
|
+
WHERE deployment_id = :deployment_id AND ingested_at > :since
|
|
1477
|
+
GROUP BY subject_entity_id
|
|
1478
|
+
) gained
|
|
1479
|
+
JOIN entities e ON e.deployment_id = :deployment_id
|
|
1480
|
+
AND e.entity_id = gained.entity_id
|
|
1481
|
+
GROUP BY e.canonical_name, gained.entity_id
|
|
1482
|
+
ORDER BY count DESC, e.canonical_name
|
|
1483
|
+
LIMIT :fetch
|
|
1484
|
+
"""
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
_AGG_TYPED_ABSENCE = text(
|
|
1488
|
+
"""
|
|
1489
|
+
-- entities of a type with NO live relation of a predicate (S40): an
|
|
1490
|
+
-- anti-join, answerable because the ontology types entities. Each bucket
|
|
1491
|
+
-- IS one absent entity (count 1), so the total is how many lack it.
|
|
1492
|
+
SELECT e.canonical_name AS key, 1 AS count, e.entity_id AS entity_id
|
|
1493
|
+
FROM entities e
|
|
1494
|
+
WHERE e.deployment_id = :deployment_id AND e.status = 'active'
|
|
1495
|
+
AND e.type = :entity_type
|
|
1496
|
+
AND NOT EXISTS (
|
|
1497
|
+
SELECT 1 FROM relations r
|
|
1498
|
+
WHERE r.deployment_id = e.deployment_id
|
|
1499
|
+
AND r.subject_entity_id = e.entity_id
|
|
1500
|
+
AND r.predicate = :predicate
|
|
1501
|
+
AND r.invalidated_at IS NULL
|
|
1502
|
+
)
|
|
1503
|
+
ORDER BY e.canonical_name
|
|
1504
|
+
LIMIT :fetch
|
|
1505
|
+
"""
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
_AGGREGATE_FORMS: dict[str, tuple[TextClause, frozenset[str]]] = {
|
|
1509
|
+
"count": (_AGG_COUNT, frozenset()),
|
|
1510
|
+
"group_by_predicate": (_AGG_GROUP_BY_PREDICATE, frozenset({"subject_entity_id"})),
|
|
1511
|
+
"group_by_object": (_AGG_GROUP_BY_OBJECT, frozenset({"subject_entity_id"})),
|
|
1512
|
+
"timeline": (_AGG_TIMELINE, frozenset({"subject_entity_id"})),
|
|
1513
|
+
"delta_top_entities": (_AGG_DELTA_TOP_ENTITIES, frozenset({"since"})),
|
|
1514
|
+
"typed_absence": (_AGG_TYPED_ABSENCE, frozenset({"entity_type", "predicate"})),
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
_SCAN_EXPORTS = {
|
|
1518
|
+
"relation": text(
|
|
1519
|
+
"""
|
|
1520
|
+
SELECT relation_id AS id, coalesce(fact_label, predicate) AS label,
|
|
1521
|
+
ingested_at AS at
|
|
1522
|
+
FROM relations
|
|
1523
|
+
WHERE deployment_id = :deployment_id
|
|
1524
|
+
ORDER BY ingested_at, relation_id
|
|
1525
|
+
"""
|
|
1526
|
+
),
|
|
1527
|
+
"observation": text(
|
|
1528
|
+
"""
|
|
1529
|
+
SELECT observation_id AS id, statement AS label, ingested_at AS at
|
|
1530
|
+
FROM observations
|
|
1531
|
+
WHERE deployment_id = :deployment_id
|
|
1532
|
+
ORDER BY ingested_at, observation_id
|
|
1533
|
+
"""
|
|
1534
|
+
),
|
|
1535
|
+
"claim": text(
|
|
1536
|
+
"""
|
|
1537
|
+
SELECT claim_id AS id, left(claim_text, 120) AS label,
|
|
1538
|
+
ingested_at AS at
|
|
1539
|
+
FROM claims
|
|
1540
|
+
WHERE deployment_id = :deployment_id
|
|
1541
|
+
ORDER BY ingested_at, claim_id
|
|
1542
|
+
"""
|
|
1543
|
+
),
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
_CONTRADICTION_MEMBERS_RELATIONS = text(
|
|
1548
|
+
"""
|
|
1549
|
+
SELECT contradiction_group, relation_id AS fact_id,
|
|
1550
|
+
coalesce(fact_label, predicate) AS label, evidence_count,
|
|
1551
|
+
valid_from, valid_until, ingested_at, invalidated_at
|
|
1552
|
+
FROM relations
|
|
1553
|
+
WHERE deployment_id = :deployment_id
|
|
1554
|
+
AND contradiction_group = ANY(:groups)
|
|
1555
|
+
AND invalidated_at IS NULL
|
|
1556
|
+
ORDER BY contradiction_group, ingested_at, relation_id
|
|
1557
|
+
"""
|
|
1558
|
+
)
|
|
1559
|
+
|
|
1560
|
+
_CONTRADICTION_MEMBERS_OBSERVATIONS = text(
|
|
1561
|
+
"""
|
|
1562
|
+
SELECT contradiction_group, observation_id AS fact_id,
|
|
1563
|
+
statement AS label, evidence_count,
|
|
1564
|
+
valid_from, valid_until, ingested_at, invalidated_at
|
|
1565
|
+
FROM observations
|
|
1566
|
+
WHERE deployment_id = :deployment_id
|
|
1567
|
+
AND contradiction_group = ANY(:groups)
|
|
1568
|
+
AND invalidated_at IS NULL
|
|
1569
|
+
ORDER BY contradiction_group, ingested_at, observation_id
|
|
1570
|
+
"""
|
|
1571
|
+
)
|
|
1572
|
+
|
|
1573
|
+
_CONTRADICTION_MEMBERS = {
|
|
1574
|
+
"relation": _CONTRADICTION_MEMBERS_RELATIONS,
|
|
1575
|
+
"observation": _CONTRADICTION_MEMBERS_OBSERVATIONS,
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
_OPEN_SUPPORT_FLAGS = text(
|
|
1579
|
+
"""
|
|
1580
|
+
-- a fact under an OPEN support_withdrawn review carries support=withdrawn
|
|
1581
|
+
-- in the envelope (D54: flagged, not vanished). "Open" is pending OR
|
|
1582
|
+
-- deferred — an 'uncertain' verdict defers but leaves the flag standing,
|
|
1583
|
+
-- matching review._SELECT_OPEN_FLAG and the lifecycle reconciler.
|
|
1584
|
+
SELECT (candidate ->> 'fact_id')::uuid AS fact_id
|
|
1585
|
+
FROM review_queue
|
|
1586
|
+
WHERE deployment_id = :deployment_id
|
|
1587
|
+
AND item_kind = 'support_withdrawn'
|
|
1588
|
+
AND status IN ('pending', 'deferred')
|
|
1589
|
+
AND (candidate ->> 'fact_id') = ANY(:fact_ids)
|
|
1590
|
+
"""
|
|
1591
|
+
)
|