Skip to content

Evidence Graph

How Ogur turns unstructured sources into structured records and links those records into a graph. This page formally introduces every schema-based extraction object, shows how they connect, walks the construction pipeline, and ends with an honest list of what the graph does not yet do.

The design is evidence-centric: the record is the node, not a flattened (subject, predicate, object) triple. That framing is borrowed from the evidence-graph literature — most directly EvidenceNet (Zong et al., 2026), which builds disease-specific biomedical knowledge graphs whose nodes are PICO-structured evidence records rather than entities. Ogur generalizes the same idea beyond biomedicine: the same record-as-node discipline ingests clinical registrations, regulatory filings, congress abstracts, SEC 10-Ks, and patents under one schema family.

These diagrams are developer documentation, not the product

Core principle #2 — the knowledge graph is infrastructure, never UI (CLAUDE.md, ux-spec §6.4). The product surfaces typed cards and EntityChips; it never renders node-edge graphs to analysts. The graphs below exist so engineers can reason about retrieval, attribution, and provenance. They are not a frontend spec.


§1 Design principle — the record is the node

A general knowledge graph flattens a finding into dupilumab —treats→ atopic dermatitis. That loses who was studied, against what comparator, at what endpoint, with what statistical support, and from which source — exactly the context a competitive-intelligence analyst needs to judge whether a number matters.

EvidenceNet keeps that context by making the evidence record a first-class node:

G = ( V_E ∪ V_T , R )

where V_E are evidence nodes, V_T are normalized biomedical entity nodes, and R are evidence→entity and evidence→evidence relations.

Ogur uses the same shape with three node families, because intelligence arrives as discrete events before it is ever distilled into evidence:

G = ( V_S ∪ V_E ∪ V_T , R )

Family Ogur tables Role EvidenceNet analog
V_SSignal nodes Signal The atomic intelligence event — one per source occurrence, content-hash unique (no analog — EvidenceNet starts at the extracted record)
V_EEvidence nodes ProtocolProfile, EvidenceRecord The PICO context + the quantitative outcome distilled from a source the evidence node (evidence_id …)
V_TEntity nodes DrugProfile, Target, CompanyProfile Normalized, deduplicated canonical entities normalized entity nodes (Cetuximab, RAS, …)
R — typed edges SignalDrug, DrugTarget, DrugSynonym Provenance- and confidence-carrying links LINKED_TO + evidence↔evidence relations

The one structural difference worth flagging up front: EvidenceNet fuses PICO and outcome into a single node; Ogur splits them into ProtocolProfile (the trial design, one row per trial) and EvidenceRecord (one quantitative result, N rows per trial), joined on trial_id. The split lets one protocol carry many endpoint readouts without duplicating the design fields.


§2 The schema-extraction objects

Every object below is a structured extraction from unstructured or semi-structured input. Grouped by graph layer. The class diagram introduces them together and shows how they link; the field cards that follow give the extracted columns and the PICO / provenance / confidence mapping for each.

classDiagram
    direction LR

    class Signal {
      +id PK
      +source, source_id
      +signal_type, severity
      +drug_name, company, target, indication
      +title, summary, raw_data
      +event_date, detected_at
      +content_hash UNIQUE
    }
    class ProtocolProfile {
      +trial_id PK
      +population
      +histology, biomarker_selection
      +comparator
      +primary_endpoint
      +line_of_therapy, blinding, dosing
      +source_nct_url, model_used
    }
    class EvidenceRecord {
      +id PK
      +trial_id, drug_name, arm, subgroup
      +endpoint, endpoint_type
      +value, unit, ci_lower, ci_upper
      +hr, p_value, comparator_value
      +raw_excerpt, source_url
      +confidence
      +content_hash UNIQUE
    }
    class DrugProfile {
      +normalized_name PK
      +brand_name, company
      +target, moa, phase
    }
    class Target {
      +normalized_name PK
      +uniprot_id, target_class
    }
    class CompanyProfile {
      +normalized_name PK
      +hq_country, stage_focus, market_cap_tier
    }
    class Briefing {
      +id PK
      +landscape_id
      +executive_summary, signal_analyses
      +kiq_answers, predictions
      +schema_valid, schema_errors
    }
    class KIQ {
      +id PK
      +question, time_horizon, priority
    }

    Signal "1" --> "0..*" EvidenceRecord : trial_id
    ProtocolProfile "1" --> "0..*" EvidenceRecord : trial_id
    DrugProfile "1" --> "0..*" Signal : SignalDrug M2M
    DrugProfile "*" --> "*" Target : DrugTarget weighted
    DrugProfile "1" --> "*" DrugSynonym : alias resolution
    CompanyProfile "1" --> "0..*" Signal : company
    KIQ "1" --> "0..*" Briefing : answered in kiq_answers
    Briefing ..> Signal : cites by id, prose not FK

Intelligence layer

Signal (signal.py) — the atomic unit every source normalizes to. Some signals are themselves LLM extractions: the Holo3 visual-intelligence sources mine earnings_narrative, job_posting, and kol_activity out of non-API surfaces (earnings calls, careers pages, congress programmes).

source · source_id          where it came from (NCT, PMID, accession, …)
signal_type · severity      20+ types (trial / regulatory / corporate / IP / visual)
drug_name · company         source-authored entity attribution (scalar, being
target · indication · moa     superseded by edge tables — see §5)
title · summary             human-readable
raw_data                    full source payload — re-parse if normalization changes
event_date                  when the underlying event happened (provenance)
content_hash                sha256(source+source_id+signal_type)[:16], UNIQUE

Evidence layer — the PICO + outcome analog

This is the layer that maps most directly onto EvidenceNet's evidence node. It is built by a separate, CLI-only evidence pipeline (§3), not during a normal briefing.

ProtocolProfile (evidence.py) — one row per trial, parsed from a ClinicalTrials.gov v2 record. It is PICO:

population            → P  Population
histology             → I  Indication / disease context
biomarker_selection   → I  enrollment biomarker (e.g. "EGFR-mutant")
comparator            → C  Comparator arm
primary_endpoint      → O  Outcome measure
line_of_therapy · blinding · dosing      study-design context
source_nct_url · extracted_at · model_used   provenance

EvidenceRecord (evidence.py) — N rows per trial; each is one quantitative outcome with its provenance string, the analog of EvidenceNet's Key statistics block:

endpoint · endpoint_type · arm · subgroup     what was measured, for whom
value · unit · ci_lower · ci_upper            the number + its interval
hr · p_value · comparator_value               effect size, significance, control arm
raw_excerpt · source_url                      PROVENANCE — "this number came from here"
confidence                                    extraction-confidence label (string)
content_hash = sha256(trial+drug+endpoint+arm+subgroup+source)[:16], UNIQUE

Extracted by outcomes_extractor.py (Haiku tool-use) from abstract / label / conference text, and entity spans by entity_extractor.py (GLiNER, F1 ≈ 0.88, with a Claude fallback).

Normalized entity layer

DrugProfile + DrugSynonym (drug.py) — the canonical drug node plus its alias table ("Keytruda", "MK-3475", "lambrolizumab" → pembrolizumab). This is Ogur's entity normalization step, EvidenceNet's "normalize against a biomedical vocabulary."

Target (target.py) — gene/protein node, HGNC symbol PK, UniProt id from Open Targets. The cleanest normalized vocabulary in the system.

CompanyProfile (company.py) — company aggregate, normalized_name PK (name is the natural identifier). Normalization here is incomplete — see §6.

Synthesis layer

KIQ (kiq.py) — a Key Intelligence Question; the analyst-authored intent the graph is queried against. Not an extraction, but it shapes which records the synthesizer must answer with.

Briefing (briefing.py) — the synthesizer's structured JSON, persisted as columns. Each active KIQ produces one structured kiq_answers block — {finding, evidence, uncertainty, implication, confidence} — gated by validate_kiq_answers before persistence. This is a schema-constrained generation step: same record-as-node discipline, applied to the synthesis output.


§3 The extraction pipeline

The four-phase flow from raw source to graph. Read it as the Ogur counterpart of EvidenceNet's workflow figure: Ingest → Extract → Normalize & Score → Construct. Dashed red boxes are capabilities that are proposed or missing (§6); the 🧠 badge marks an LLM-driven step.

flowchart LR
    subgraph P1["1 · Ingest and Normalize"]
        direction TB
        SRC["10 source adapters<br/>CT.gov · PubMed · OpenFDA<br/>SEC · Lens/EPO · Holo3 🧠"]
        SIG["Signal<br/>content_hash dedup"]
        SRC --> SIG
    end

    subgraph P2["2 · LLM-Driven Extraction"]
        direction TB
        FILT["Eligibility pre-filter<br/>OUTCOME_BEARING types"]
        CT["ct_gov_v2 parser<br/>pure Python"]
        OUT["outcomes_extractor 🧠<br/>Haiku tool-use"]
        NER["entity_extractor 🧠<br/>GLiNER / Claude"]
        PP["ProtocolProfile<br/>PICO"]
        ER["EvidenceRecord<br/>outcome"]
        FILT --> CT --> PP
        FILT --> OUT --> ER
        FILT --> NER
    end

    subgraph P3["3 · Normalize and Score"]
        direction TB
        SYN["DrugSynonym<br/>alias to generic"]
        TGT["Target<br/>HGNC / UniProt"]
        SCORE["composite quality score<br/>design · impact · stats · n<br/>to A-D grade"]
    end

    subgraph P4["4 · Graph Construction"]
        direction TB
        E1["SignalDrug · DrugTarget<br/>link_method + confidence"]
        E2["SignalTarget · SignalCompany<br/>SignalIndication"]
        EE["evidence-evidence relations<br/>SUPPORTS · CONTRADICTS · REFINES"]
        G[("Evidence Graph")]
        E1 --> G
        E2 --> G
        EE --> G
    end

    SIG --> FILT
    ER --> SYN
    PP --> SYN
    NER --> TGT
    SYN --> E1
    TGT --> E1
    SYN -.-> SCORE
    G --> BRIEF["Briefing 🧠<br/>Sonnet synthesis"]

    classDef gap fill:#F2D2CC,stroke:#8B2820,color:#2E0D10,stroke-dasharray:5 3;
    class SCORE,E2,EE gap;

Two boundaries carry over from architecture.md:

  1. Ingestion is separate from synthesis. Phases 1 and 2–4 never run in the same process. Signal rows are written once by seed scripts; everything downstream reads them.
  2. Dedup is load-bearing. Both Signal and EvidenceRecord carry a deterministic content_hash with a DB-level UNIQUE constraint. Re-running extraction (prompt iteration) cannot silently duplicate a record.

§4 The transformation — one source, three views

The Ogur counterpart of EvidenceNet's transformation figure: a single source statement traced from raw text → structured record → graph. The worked example is an immunology head-to-head readout (dupilumab vs. an IL-13 comparator on EASI-75).

flowchart LR
    subgraph RAW["1 · Raw source"]
        direction TB
        NCT["NCT05012345<br/>CT.gov v2 protocol JSON"]
        ABS["Conference abstract<br/>'EASI-75 58% vs 40%<br/>at wk 16; p = .18'"]
    end

    subgraph STRUCT["2 · Structured extraction"]
        direction TB
        PP["ProtocolProfile<br/>population: mod-severe AD<br/>comparator: IL-13 mAb<br/>primary_endpoint: EASI-75<br/>blinding: double-blind"]
        ER["EvidenceRecord<br/>endpoint: EASI-75<br/>value: 58 · comparator_value: 40<br/>p_value: .18 · confidence: medium<br/>raw_excerpt: '…58% vs 40%…'"]
    end

    subgraph GRAPH["3 · Graph"]
        direction TB
        ERN["EvidenceRecord node"]
        DRUG(("dupilumab"))
        TGT(("IL-4Rα"))
        CO(("Sanofi / Regeneron"))
        CMP["competing EvidenceRecord<br/>IL-13 comparator"]
        ERN -->|trial_id| PP
        ERN -. LINKED_TO .-> DRUG
        DRUG -->|"DrugTarget · conf 0.9"| TGT
        DRUG -. SignalCompany .-> CO
        ERN -. CONTRADICTS .-> CMP
    end

    NCT --> PP
    ABS --> ER
    ER --> ERN
    PP --> ERN

    classDef gap fill:#F2D2CC,stroke:#8B2820,color:#2E0D10,stroke-dasharray:5 3;
    classDef solid fill:#D4DDD3,stroke:#345042,color:#0E1F17;
    class DRUG,TGT,ERN,PP solid;
    class CO,CMP gap;

The graph layer carries two edge families, exactly as EvidenceNet does — but Ogur has built only one of them:

  • Record → entity edges (LINKED_TO analog): solid. SignalDrug, DrugTarget, DrugSynonym. Ogur's version is richer than EvidenceNet's bare LINKED_TO — every edge carries link_method, confidence, and matched_evidence (§5).
  • Record → record edges (SUPPORTS / CONTRADICTS / REFINES / …): dashed. These do not exist in Ogur. The CONTRADICTS edge above is computed on read by the head-to-head route, never stored. This is the central gap (§6.1).

§5 Graph construction — nodes and edges

The edge model is specified in full by ADR-0002 — Knowledge Graph Roadmap; this section is the introductory view. The decision recorded there: typed entity tables + typed edge tables in SQL + a thin Python traversal helper — no graph database. At ~4k signals and ~50 drugs, composite primary keys and indexes give cheap lookups; Python composes the traversals.

flowchart TB
    subgraph NODES["Entity nodes (V_T)"]
        direction LR
        D(("Drug")) ; T(("Target")) ; C(("Company")) ; I(("Indication")) ; PW(("Pathway"))
    end
    subgraph RECS["Record nodes (V_S, V_E)"]
        direction LR
        S(("Signal")) ; EV(("EvidenceRecord")) ; PR(("ProtocolProfile"))
    end

    S -->|"SignalDrug ✅"| D
    D -->|"DrugTarget ✅"| T
    D -->|"DrugSynonym ✅"| D
    EV -->|"trial_id ✅"| PR

    S -.->|"SignalTarget ⛔"| T
    S -.->|"SignalCompany ⛔"| C
    S -.->|"SignalIndication ⛔"| I
    D -.->|"DrugIndication ⛔"| I
    T -.->|"TargetPathway ⛔"| PW
    EV -.->|"SUPPORTS / CONTRADICTS ⛔"| EV

    classDef built fill:#D4DDD3,stroke:#345042,color:#0E1F17;
    class D,T,C,I,PW,S,EV,PR built;

✅ = built · ⛔ = proposed in ADR-0002, not yet implemented.

Canonical edge shape. Every edge follows the SignalDrug pattern — and this is where Ogur's provenance discipline (principle #1) becomes structural rather than textual:

class FromTo(SQLModel, table=True):
    from_id: str  = Field(foreign_key=..., primary_key=True)
    to_id:   str  = Field(foreign_key=..., primary_key=True)
    link_method: str        # source_column | synonym_text_match | llm_extraction | open_targets | manual
    confidence:  float      # 0.0–1.0, calibrated per link_method
    matched_evidence: str | None   # the snippet / OT id / PMID that justified the edge
    created_at: datetime

link_method is a small closed enum, not free text — every method has a calibrated confidence range, so an edge can be defended on the page where it surfaces. The composite primary key makes every backfill idempotent. DrugTarget additionally carries evidence_count, a corroboration counter that increments each time a new source confirms the same pair — a weighted edge in all but name.


§6 Gaps versus an evidence-centric graph

Ogur's entity-attribution layer is more mature than EvidenceNet's (provenance + calibrated confidence on every edge). The gaps are in the evidence layer — the parts of EvidenceNet that turn isolated records into a reasoning fabric. Each gap below is mapped to existing code or an ADR so it is actionable, not aspirational.

# EvidenceNet capability Ogur status Where it would live
1 Evidence ↔ evidence relations (SUPPORTS / CONTRADICTS / REFINES / EXTENDS / REPLICATES / CAUSAL_CHAIN) Missing new EvidenceEvidence edge table; relation induction stage
2 Composite evidence-quality score S(e) → A–D grade Missing — only a string confidence evidence.py, cf. ADR-0003
3 Cross-document semantic fusion + merged-record provenance / versioning Partial — exact-key dedup only compute_evidence_content_hash, matcher.py
4 Entity normalization to a canonical vocabulary Partial — drugs/targets yes, companies/indications no ADR-0002 Phase 2–3
5 Materialized, queryable graph (serialized + traversal API) Missing — edges in SQL, no traversal layer ogur/graph/ (ADR-0002 Phase 4)
6 Structural provenance link synthesis → evidence Missing — citation is prose, not an edge briefing.py, synthesizer.py

§6.1 — No evidence ↔ evidence relations (the biggest gap). This is EvidenceNet's signature contribution and Ogur's most conspicuous absence. Ogur links records to entities but never to each other. Two EvidenceRecords reporting the same endpoint for competing drugs are not joined by a CONTRADICTS or comparative edge — the head-to-head card in routes/evidence.py computes that comparison deterministically on read and throws it away. Cross-trial corroboration (REPLICATES), a mature readout superseding an interim (REFINES), and mechanistic chains (CAUSAL_CHAIN) are all invisible. Building this means a relation-induction stage (deterministic proposal on shared entity + endpoint, LLM verification on ambiguous pairs — EvidenceNet's hybrid strategy) writing to a new EvidenceEvidence table that reuses the §5 edge shape.

§6.2 — No composite quality score. EvidenceNet computes a weighted score — roughly S(e) = (w1·design + w2·impact + w3·stats + w4·sample)·(1−λ) + λ·LLM_conf — and maps it to A–D grades. Ogur's EvidenceRecord.confidence is a bare extraction-confidence string ("medium") — it says nothing about study design or statistical support, even though ProtocolProfile already stores blinding, comparator, and line_of_therapy. There is no way to rank a phase-3 double-blind RCT readout above a single-arm conference abstract. ADR-0003 ranks drugs; an evidence-level S(e) is the missing sibling. (Note: because Ogur spans domains, S(e) must be domain-aware — a 10-K narrative and an RCT readout cannot share one weighting.)

§6.3 — Exact-key dedup, no semantic fusion. content_hash collapses identical re-extractions, but the same outcome reported first in an abstract and later in a full paper yields two records with different source_id and is never fused. EvidenceNet does fingerprint + semantic dedup + attribute merging with version provenance (~38% of its records have version > 1). Ogur has the raw material for this — matcher.py already scores protocol similarity — but it is used for competitive callouts, not for canonical-record fusion with a lineage trail.

§6.4 — Partial entity normalization. Drugs (DrugSynonym) and targets (HGNC + UniProt) are well normalized. Companies and indications are not: "REGENERON PHARMACEUTICALS, INC." and "Regeneron" are two distinct nodes today, and indications are stringly-typed. ADR-0002 Phase 2 (SignalCompany) ships the first company-normalization pass; indication clustering (the atopic march) is Phase 3.

§6.5 — The graph is not yet a queryable object. Nodes exist and a few edge types exist, but "graph construction" is only partial: there is no serialized graph and no traversal layer. Multi-hop questions ("all signals across the IL-4Rα / type-2 axis") still require ad-hoc Python joins. ADR-0002 Phase 4 (ogur/graph/ with signals_for_drug(drug, via="pathway", hops=3)) is the migration point.

§6.6 — Provenance is textual, not structural, at the synthesis boundary. A Briefing references trials by NCT id inside prose (signal_analyses, predictions), with no foreign key into the evidence layer — intentionally one-way (architecture.md §2). This satisfies principle #1 at the UI level (every claim gets a <SourceChip>) but not structurally: there is no graph edge from a synthesized claim back to the EvidenceRecord that grounds it, so "show me the evidence behind this implication" cannot be answered by traversal.

Fidelity baseline

EvidenceNet reports component-level audits (98.3% field extraction, 100% high-confidence entity-link, 87.5% fusion integrity, 90% relation-type accuracy). Ogur's analogs today are GLiNER entity F1 ≈ 0.88 and the per-drug evidence-pipeline pilot report (confidence distribution, null-field rate). There is no fusion-integrity or relation-type-accuracy metric — because, per §6.1 and §6.3, those layers are not built yet. Standing up gaps 1 and 3 should ship with the eval harness that measures them.