Synced verbatim from
fornax-coreat build time — edit it there, not here.
ADR 0017 — Evidence Source-Dependency Families: Real vs. Unexercised
Status: Accepted
Ticket: FORNX-347 · Epic: FORNX-340 (Stage 8 / Active Evidence Intelligence)
Crate: fornax-verify (independence.rs)
Context
FORNX-347's own "Background" section states "FORNX-92 already provides
correlation_group, derivation lineage, freshness and trust classes" and
asks for a "source/dependency DAG across model-authored outputs,
tool-derived summaries, deterministic probes, external systems and derived
findings." That framing is structurally true but operationally misleading —
the same "ticket prose vs. reality" gap this session's other Stage-8 tickets
found.
The real gap is not what the ticket implies
EvidenceSource::correlation_group has zero real writers anywhere in
this workspace — every shipped sensor calls EvidenceSource::now(..), which
hardcodes correlation_group: None. FusionRule::CorrelationCollapsed and
voi::Independence::SameSourceAsCounted were consequently unreachable on
real traffic before this ticket (ADR 0015 already conceded this). The
actual, live, on-real-traffic common-source amplification runs through a
column nobody was reading for this purpose: Evidence::source_event_id, a
NOT NULL persisted column every sensor stamps. fornax-adapter-claude's
translate() fans one AgentEvent out to multiple sensors — a Bash
PostToolUse on a git commit produces both ClaudeBashExitCodeSensor and
ClaudeGitOutcomeSensor evidence, both TrustClass::AgentAdjacent, both
reading the same tool_response, both stamped with the same
source_event_id. Fusion counted those as two independent supporting
votes. This is the real defect this ticket fixes.
The "5-category source DAG" the ticket describes maps almost entirely onto
TrustClass's existing five variants — a flat tag, not a graph. The graph
structure genuinely missing was evidence→evidence, and its real edges are
derived_from (already present, one real writer: judge.rs) and
source_event_id (always present, never read for this purpose before now).
Decision: read-side union-find, no new writer, no migration
fornax_verify::independence::SourceFamilyMap builds a union-find over one
evidence pool from three real relations: explicit correlation_group
(FORNX-92, honored when present), transitive derived_from ancestry (not
just one level — see ancestors_of), and same source_event_id on the
agent-reported channel only (AgentAdjacent/ModelInternal). Never
HostObserved/IndependentExternal/HumanReviewed — two independently
observing host sensors on the same event are genuinely distinct
observations (e.g. ClaudeFileWriteConfirmedSensor's std::fs::metadata
check vs. ClaudeGitWorkingTreeSensor's in-process git query on the same
Edit event), and collapsing them would under-count Fornax's most valuable
evidence — the unsafe direction. Evidence with no recorded source is
always its own singleton.
Read-side only, never persisted, no new column, no adapter change, no
migration — mirrors EvidenceGraph's own "read-side aggregate" discipline.
Additive unions: explicit metadata can never prevent a structural collapse
The North Star invariant ("correlated evidence must never be counted as
independent") cuts one way: unions only ever grow. An explicit
correlation_group can record that two records share a source, but
recording two different explicit groups on records that structurally
share the same real source_event_id does not un-share that event. Fusion
(FusionRule::CommonSourceCollapsed, R5b) implements this as a two-phase
process: phase 1 keeps R5's existing exact-correlation_group collapse
byte-identical; phase 2 applies the family map to every phase-1 survivor,
not just originally-ungrouped links, so a cross-explicit-group same-event
pair still collapses. This edge case was caught by a failing test during
development — the first implementation only checked originally-ungrouped
links and missed it.
Collapse can only ever suppress corroboration-count inflation
CommonSourceCollapsed (like CorrelationCollapsed before it) always
keeps exactly one representative per (relation, family) bucket — s and
c_count (the supports/contradicts counts fusion decides on) never drop to
zero where they weren't already zero. This means collapse can demote
Corroborated to Qualified, but it can never flip Verified to anything
else, and it can never manufacture a contradiction. Pinned by a dedicated
test (common_source_collapse_stays_qualified_never_corroborated) and by
the fornax-bench regression fixture below.
Independence::PartiallyCorrelated is now reachable
voi::independence_of previously had exactly two live outcomes
(IndependentOfCounted/Unverified) plus one fixture-only outcome
(SameSourceAsCounted, requiring an explicit group). PartiallyCorrelated
existed in the enum but no code path could ever return it. It is now
returned when a probe's trust class matches counted evidence spanning two
or more distinct source families — some of what fusion counted really is a
different source, but not all of it. EvidenceGapKind::SingleSourceCorroboration
is re-keyed the same way; since R5b already collapses same-family/
same-relation votes to one, it can now only fire in the narrow case where a
single family spans both relations (a source that both supports and
contradicts the claim) — real and interesting, but narrower than before
this ticket.
"Evidence Explorer" maps to two real surfaces, not a UI
No web UI exists in this repository for evidence — docs//the daemon
source contain no such thing. FORNX-90's "Evidence Explorer" is the name
for GET /api/evidence-graph plus fornax evidence-graph's text renderer;
/dashboard is a small hardcoded HTML table of recent findings, unrelated.
AC6 ("Evidence Explorer exposes source-family/dependency rationale without
requiring graph-theory expertise") is scoped down honestly to exactly these
two surfaces: api_evidence_graph gains a source_families array
(claim-scoped, never leaking unrelated session families) and a
source_family index per link; render_evidence_graph gains a plain-prose
section ("family N — K records, counted as ONE source: same agent turn
(event ...)") with zero graph/union-find vocabulary, shown only when at
least one family genuinely groups more than one record. Inventing a real
graphical UI would be new, unscoped work — not attempted here.
Version bumps
BaselineFusionPolicy::policy_version() 1→2 (covers both the transitive-R3
widening and R5b together — one bump, not two). DeterministicVoiPolicy::policy_version()
1→2 (covers both the family-based independence_of rewrite and the
re-keyed SingleSourceCorroboration). Two real call sites that assert
against the live version number were updated
(fornax-replay's drift test, fornax-bench's manifest identity test);
several other fusion_policy_version: 1/fusion_policy_version: 2
literals elsewhere in the workspace are hand-built test fixtures unrelated
to the real policy and were correctly left untouched.
Cannot be verified without further work
- The
derived_fromtransitive-closure widening is correct but currently unexercised on real traffic. Onlyjudge.rswritesderived_from, and that evidence (/api/judge's response) is never persisted or linked to a claim today — same caveat class as ADR 0015's gap #1. The(source_event_id, agent-channel)union, by contrast, is live today, confirmed against three real adapter sensors on one event. - Grouping
ModelInternalinto the agent channel is forward-looking, not yet exercised. Judge evidence is stamped withclaim.source_event_id(the claim-producing event, e.g.SessionEnd) — not thePostToolUseevent behind the tool-adjacent evidence it might otherwise correlate with — so rule 3 never actually unions judge output with tool evidence today. Justified as forward-looking for a future reasoning-summary sensor, not claimed as a currently-live path. - Anything resting on
correlation_groupalone stays dead, includingUncertaintyBand::Corroborateditself — this module reads the explicit field when present but must never depend on it being present. SingleSourceCorroboration's new narrow trigger has no real-traffic confirmation. It requires one family spanning both relations, which no fixture beyond this ADR's own test currently exercises against captured data.- Not attempted, and explicitly out of scope by the ticket's own AC3
wording: a configurable unknown-dependency policy. This repo's
equivalent knob is a versioned
FusionPolicy/VoiPolicyimplementation, not a config field — "conservative" is met (unknown provenance is always a singleton, never independence), "configurable" is a deliberate scope decision, not a gap. - Statistically calibrated independence is not attempted, per the
ticket's own explicit prohibition — no numeric score is serialized
anywhere in this module;
UtilityEstimate's ordinal-not-probability discipline (ADR 0015) is preserved unchanged.
Out of scope, tracked as real follow-up work, not silently dropped:
stamping correlation_group at the adapters on the persisted ingest path
(FORNX-92's own unfinished half — five-plus call sites across three adapter
crates); persisting/linking judge evidence into the graph (would make gap
#1 above live); a real Evidence Explorer UI (FORNX-90's unfinished half).
Escalation
None. This is read-side derivation over columns already persisted: no
migration, no new writer, no new dependency, no side effect, no
irreversible action. The one change that would warrant an owner decision
— altering the adapters to stamp correlation_group on the persisted
ingest path — is deliberately excluded from this ticket's scope, and that
exclusion is precisely what keeps this a routine engineering change.
Verification
10 unit tests in independence.rs cover the load-bearing safety property
(two HostObserved records on the same event never collapse), transitive
ancestry through an unlinked intermediate, cycle safety, the
unknown-provenance singleton, and the additive-union invariant. 5 new
fusion.rs tests cover the live real-traffic collapse shape, the
HostObserved-sibling safety property at the fusion level, the
verdict-never-flips invariant, and the cross-explicit-group same-event edge
case found during development. 2 new voi.rs tests cover
PartiallyCorrelated reachability and the narrowed
SingleSourceCorroboration trigger. A real end-to-end daemon test against a
seeded store confirms GET /api/evidence-graph surfaces a genuine
common-source family from two real sensor-shaped evidence rows. A
fornax-bench fixture, run through the real run_harness pipeline (not
mocked), proves the naive per-group-counting reading (3 distinct groups →
Corroborated → Proceed) never happens once source_event_id-based
collapse is applied — the actual, concrete false-uplift prevention this
ticket's AC5 asks for.