Epistemic-columns currency (Seam 1) — consuming epistemic-graph's KnowledgeBatch¶
The keystone cross-repo seam. agent-utilities' primary query path used to flatten every engine result to a plain
dict, dropping the epistemic columns (eg_plan::KnowledgeSet/KnowledgeBatch, CONCEPT:EG-P1-2) the Rust engine had already computed for those exact rows — confidence, a bitemporal valid/tx window, evidence provenance, policy labels. This page documents the wire surface added to close that gap and the ONE primary AU path (KnowledgeGraph.query) that adopts it. Concepts: CONCEPT:EG-KB-CURRENCY (engine side), CONCEPT:AU-KB-CURRENCY (facade side).
The gap¶
epistemic-graph resolves a rich per-row envelope internally
(crates/eg-plan/src/knowledge.rs's KnowledgeRow / knowledge_batch.rs's
KnowledgeBatch) — score, belief confidence, (valid_from, valid_until),
(tx_from, tx_to), evidence spans, source refs, policy labels, and more. But the
query surfaces a Python caller actually reaches were narrower:
Method::UnifiedQuery/UnifiedQueryTextreturn bare[id, score|nil]rows — no epistemic columns at all.Method::ExplainProvenancecame closer (it already builds aKnowledgeSetper request) but its wire row (ExplainProvenanceRowWire) only carriedid/kind/source_refs/evidence_spans— confidence, the bitemporal window, and policy labels were computed server-side and then dropped before the response left the engine.- On the AU side,
agent_utilities/knowledge_graph/facade.py'sKnowledgeGraph.query(the guarded, tenant-scoped, audited Cypher read every execution-plane caller uses) returnedlist[dict]built byEpistemicGraphBackend.execute— plain node property projections, with no path to the engine's epistemic envelope at all.
The wire surface (EG side)¶
Two additive, non-breaking changes in epistemic-graph:
ExplainProvenanceRowWire(crates/eg-types/src/protocol.rs) widened withscore,confidence,valid_time,tx_time,policy_labels— straight field copies off theKnowledgeRowthe handler already builds (src/server/handlers/query.rs'sexplain_provenance_result).score/confidence/valid_time/tx_timeare populated in either case of theepistemiccargo feature;source_refs/policy_labels/evidence_spansstay empty (never fabricated) whenepistemicis off, exactly asresolved: falsealready documented.Method::ExplainProvenanceByIds { ids: Vec<String> }— a new, ID-seeded sibling ofMethod::ExplainProvenance. A caller that already has a set of node ids from ANY other read path (a CypherMATCH, a SQLSELECT, a priorUnifiedQuery) does not must hand-build anOpplan just to fetch the epistemic envelope for those exact ids — it builds theKnowledgeSetstraight fromRowSet::from_ids(ids)and resolves the IDENTICAL row shapeExplainProvenancedoes. This is the primitive AU's facade calls.
Both changes are purely additive: no existing Method variant's shape changed, no
existing wire field was removed or renamed, and the capability-policy ledger
(eg-capabilities) was updated in lockstep (ExplainProvenanceByIds classified
identically to ExplainProvenance — read-only, snapshot-isolated, explain:read).
Python surface: epistemic_graph/client.py's QueryClient.explain_provenance_by_ids
mirrors the existing explain_provenance convenience method.
The AU primary path¶
agent_utilities/knowledge_graph/facade.py's KnowledgeGraph.query gained one new,
default-off parameter:
- Default (
include_epistemic=False, unchanged) — byte-for-byte the samelist[dict]every existing caller already gets. - Opt-in (
include_epistemic=True) — the SAME rows, widened. The method: - runs the Cypher exactly as before, through the existing guarded path (tenant scope, ACL row-filter, fine-grained object permissioning, read audit) — nothing about the read's authorization changes;
- extracts the distinct node ids the plain rows project
(
core/epistemic_row.py'srow_ids_from_plain_rows— recognizes both a bareRETURN nnode-dict projection and aRETURN n.id AS idscalar projection); - resolves their epistemic envelope in ONE round-trip via
GraphComputeEngine.explain_provenance_by_ids(Method::ExplainProvenanceByIds); - returns
list[EpistemicRow]— each one the engine's envelope zipped back with the plain row's own properties, so opting in never loses information a plaindictrow will have carried.
EpistemicRow (agent_utilities/knowledge_graph/core/epistemic_row.py) is the typed
carrier: id, kind, score, confidence, evidence_refs, source_refs,
valid_time, tx_time, policy_labels, properties — plus a calibration property
that aliases confidence (the engine substrate's confidence value IS the calibration
signal it produces today; there is no second, independently-computed calibration
column on the wire, so this is documented as an alias rather than a fabricated second
number).
Proof¶
tests/integration/knowledge_graph/test_kb_currency_epistemic_facade.py writes a
Claim + Evidence node pair (with a real confidence, a bitemporal window, and a
SUPPORTS edge) directly into a real, ephemeral epistemic-graph-server, then
asserts KnowledgeGraph.query(..., include_epistemic=True) returns an EpistemicRow
whose confidence/valid_time/tx_time match what was written AND whose
source_refs/policy_labels are the belief-substrate's OWN derived values from the
SUPPORTS edge (not stored properties, not client-side echoes) — proving the
envelope originated in the engine's KnowledgeSet, not fabricated AU-side.
Adoption path for other AU read surfaces (shipped)¶
The keystone slice above shipped on ONE primary path (KnowledgeGraph.query). The
follow-up workstream threaded the SAME pattern — a shared
agent_utilities.knowledge_graph.core.epistemic_row.attach_epistemic_rows(rows, fetch)
helper (extract ids in the rows' own order via row_ids_from_plain_rows, resolve
them in one round-trip via an id-seeded explain_provenance_by_ids-shaped fetch,
zip each engine row back with the plain row's own properties) — through every other
read surface that returned bare rows:
GraphComputeEngine.query_unified(..., include_epistemic=True)(agent_utilities/knowledge_graph/core/graph_compute.py) andIntelligenceGraphEngine.uql(..., include_epistemic=True)(agent_utilities/knowledge_graph/orchestration/engine_query.py) — the cross-modalUnifiedQuery/UnifiedQueryTextsurfaces. Both default off (byte-for-byte the existing[{"id", "score"}]rows) and, opted in, currency-upgrade the SAME ids in the SAME post-Rankorder intolist[EpistemicRow]. Proof:tests/integration/knowledge_graph/test_kb_currency_epistemic_query_paths.py::test_query_unified_include_epistemic_carries_engine_envelopeand::test_uql_include_epistemic_carries_engine_envelope— both seed a real Claim+Evidence(+SUPPORTS) pair into a real ephemeral engine and assert the returned confidence/bitemporal window/source_refs/policy_labelsare the engine's own resolution.GraphBackend.execute(..., include_epistemic=True)(the ABC inagent_utilities/knowledge_graph/backends/base.py) — thestore.executecounterpart ofKnowledgeGraph.query's opt-in, for internal/unscoped callers that bypass the facade.EpistemicGraphBackend.execute(the one backend whoseGraphComputeEngineexposesexplain_provenance_by_ids) implements it for real (ownexecutebody renamed to_execute_rows, with a thin wrapper attaching the envelope).FanOutBackend.executeforwards the flag to its authority backend on the read path (honored when the authority itself supports it). Every OTHER concrete backend (AGEBackend/PostgreSQLBackend/Neo4jBackend/FalkorDBBackend/LadybugBackend) has no id-seeded epistemic-envelope primitive, so aTruerequest degrades to[](never raises, never silently returns plaindictrows under aTruerequest) — the documented ABC contract. Proof:test_kb_currency_epistemic_query_paths.py::test_store_execute_include_epistemic_carries_engine_envelope(real engine).- Typed evidence-span view —
EpistemicRow.evidence_refsstill carries the wire's rawEvidenceSpanWiredicts verbatim (never removed, so nothing regresses for an existing caller), but a newEpistemicRow.typed_evidence_refsproperty andEvidenceSpandataclass (core/epistemic_row.py) parse each externally-tagged{"<Variant>": {...}}wire entry into a typed, attribute-accessible view (one dataclass covering all 11eg_modality::EvidenceSpanvariants' fields, plus arawescape hatch for a field this dataclass hasn't been widened for). An entry that doesn't parse as a recognized single-key-map shape is skipped, never fabricated. Proof:tests/unit/knowledge_graph/core/test_epistemic_row.py.
Light layer — default-on, additive columns (Native by default)¶
Everything above is the HEAVY, opt-in, type-changing path (include_epistemic=True
→ list[EpistemicRow]) — correct for a caller that wants the full typed envelope,
but it stayed off by default because flipping a facade's return type from
list[dict] to list[EpistemicRow] for every existing caller will be a breaking
change, and a naive default flip will silently empty results on every backend
except EpistemicGraphBackend (the documented "degrades to []" contract above).
agent_utilities/knowledge_graph/core/epistemic_row.py's
attach_epistemic_columns(rows, fetch) is the LIGHT counterpart that closes that
gap without either problem: it merges confidence/source_refs/evidence_refs/
policy_labels/provenance onto each row in place via dict.setdefault
(never clobbers a property the row already carries under one of those names) and
returns the SAME list[dict] — the return type never changes, so it is safe to run
by default on every plain read.
Wired, default-on, into the three primary read surfaces (right where
include_epistemic=False's branch used to just return rows):
KnowledgeGraph.query, GraphComputeEngine.query_unified,
IntelligenceGraphEngine.uql. Governed by config.epistemic_light_default
(KG_EPISTEMIC_LIGHT_DEFAULT, default true — see configuration.md); when an
operator opts it off, should_attach_epistemic_columns still forces the attach for
a row that already shows a contested/low-confidence signal in its own properties
(is_contested_row, a cheap local check, no RPC) — the light layer is auto-on for
contested/low-confidence results even under an opt-out default. Degrades to a
neutral prior (confidence=0.5, empty ref/label lists,
provenance={"resolved": false, ...}) exactly where the heavy path degrades to
[] — a backend with no explain_provenance_by_ids never errors and never loses
the row's own data.
attach_epistemic_columns also stamps the resolved envelope onto the CURRENT OTel
span (best-effort, no-op without a configured pipeline) via
observability.get_telemetry_engine().annotate_epistemic — epistemic.confidence/
epistemic.status/epistemic.contradiction_count/epistemic.policy_labels +
gen_ai.response.source_count, so the SAME light envelope that already reaches
ContextCompiler (which reads these exact column names — CONCEPT:EPI-P3-1) is also
visible on a plain Tempo/Grafana/Langfuse trace view, closing the
04-five-intersections.md item 4 gap ("zero gen_ai.* hits anywhere in the
package"). The same annotation call is wired into the A2A remote-agent execution
path (graph/executor.py's _execute_agent_package_logic) via
A2AClient.execute_task_with_epistemic — the additive sibling of execute_task
that also surfaces a peer's response metadata (including any epistemic fields it
attached) instead of silently discarding it, and every agent-utilities A2A server's
AgentCard now advertises an epistemic-answer skill
(epistemic_status/why/what_changed over the shared KG, server/app.py).
Proof: tests/unit/knowledge_graph/core/test_epistemic_row.py (TestAttachEpistemicColumns,
TestShouldAttachEpistemicColumns, TestIsContestedRow, TestEpistemicStatus),
tests/unit/test_telemetry_engine.py (annotate_epistemic), and
tests/unit/protocols/test_a2a.py (execute_task_with_epistemic).
What remains for full adoption (honest scope)¶
One documented gap remains — the Arrow/columnar surface:
KnowledgeBatch's Arrow/columnar surface is NOT wired to any AU caller, and genuinely can't be with additive Python-only changes — this was investigated, not just deferred. Concretely, as of this workstream:KnowledgeBatch/to_record_batch/ChunkedKnowledgeCursor::to_arrow_ipc_stream(epistemic-graph/crates/eg-plan/src/knowledge_batch.rs) exist and are unit- tested, but live behind theknowledge-batchcargo feature (crates/eg-plan/Cargo.toml), which is not part offull/default— a stock server build (including the one this workstream tested against) links no Arrow at all for this path.- There is no
Methodvariant, noeg-typeswire DTO, and no dispatch/handler (src/server/dispatch.rs/src/protocol.rs/src/server/handlers/query.rs) that builds aKnowledgeSet/KnowledgeBatchand serializes it back over the existing MessagePack RPC transport —KnowledgeBatchis reachable only from Rust code already inside theeg-plancrate. The ONE existing server-side Arrow export (src/server/dataset_handle.rs) is a completely separate path built fromeg_query::exec_sql_arrow, not from aKnowledgeSet, and isn't reused here. - The Python client (
epistemic_graph/client.py) has no Arrow/PyArrow dependency or IPC-decoding method at all. - What shipping this for real needs (EG-side, not a Python-only change): (1) a
new
Method::ExplainProvenanceBatch{ByIds}-shaped variant +eg-typeswire DTO carrying raw Arrow IPC-stream bytes (Vec<u8>), gated behindknowledge-batch(which the facade'sCargo.tomlwill must also forward intofullor a new opt-in server feature); (2) a dispatch/handler arm that resolves aKnowledgeSet(the SAMERowSet::from_idsprimitiveExplainProvenanceByIdsalready uses) and encodes it viaChunkedKnowledgeCursor::to_arrow_ipc_streaminto the response bytes; (3) anepistemic_graphPython client method that depends onpyarrowto decode the IPC stream into aRecordBatch/Table; (4) an AU consumer (e.g.GraphComputeEngine.explain_provenance_batch_by_ids) exposing thatTableto a bulk/vectorized caller. This is a genuinely new wire surface spanning both repos, not an extension of the additiveExplainProvenanceByIdspattern this seam otherwise reused everywhere else — tracked as a separate, larger follow-up.