Glean Health — Data Schema & Lineage

v2 — 2026-06-01 — table-by-table walkthrough of how a treatment signal is built, end-to-end. Numbers below reflect corpus state at last manual refresh; live counts in dev_status.html.
deterministic external API LLM call in-memory only deferred / not yet built
Lineage (9 stages, 4 persisted layers) — stage 3 (condition-context filter) was specified in early-prototype design but never implemented; each condition's sources[] list pre-vets subreddit relevance, so a separate keyword pass was redundant. Officially retired 2026-05-25.
  1. Ingest — Arctic Shift → data/raw/
  2. Hard filterdata/raw/data/filtered/
  3. LLM extractdata/filtered/data/extracted/<dir>/<id>.json
  4. Author authority track — Arctic Shift per-author pulls → data/authors/<shard>/<author>.jsonl + _features.json
  5. Mentions table (the join) — extracted × filtered → in-memory rowset
  6. Treatment clustering — RxNorm + fastembed → in-memory clusters
  7. Per-cluster aggregation — clusters × author credibility → cluster rows
  8. PubMed enrich + pro/con/neutral classify — clusters × PubMed eUtils + Haiku 4.5
  9. Final output + surfacesdata/pipeline/<condition>_*

Data lineage at a glance

Single canonical source: data_lineage.mmd. Companion to pipeline.mmd (processing-stage view). Guarded by scripts/check_no_dup_diagrams.py. View fullscreen ↗
26
conditions live
102,458
extractions on disk
133,190
treatment mentions
16,568
unique clusters (post-noise-filter)
The universal join key is item.id — Reddit's globally-unique post/comment ID. Every persisted layer carries it. The mentions table (stage 6) is the inner join of data/extracted/<dir>/<id>.json with data/filtered/<sub>/*.jsonl on this key.

1 Ingest deterministic Arctic Shift API

Source:Arctic Shift HTTP API (Reddit historical archive) Command:python ingest/ingest_sub.py --sub <sub> --kind both --after YYYY-MM-DD --before YYYY-MM-DD Output:data/raw/<sub>/{posts,comments}_<after>_<before>.jsonl Granularity:One JSONL line per Reddit submission or comment Idempotency:Skips if output file already exists

Submission record — Arctic Shift returns the full Reddit JSON (~100 fields). We keep them all; downstream uses only ~10. Currently retained per ingest: 99 fields/submission

FieldTypeUsed downstream by
idstring (base36)universal join key
authorstringstage 6 credibility, stage 8 distinct_authors, drill-down
subredditstringstage 8 distinct_subs, drill-down
titlestringstage 4+5 LLM input, drill-down
selftextstringstage 4+5 LLM input, body_snippet, length filter
scoreintstage 2 hard filter
created_utcint (epoch)provenance
permalinkstring(unused, available for drill-down link)
num_comments, upvote_ratio, over_18, ...various(unused)
_source, _retrieved_at, _metaprovenancefuture multi-source disambiguation

Comment record — same shape minus title/selftext, with body and parent_id/link_id instead. ~80 fields/comment

What we're persisting: raw verbatim Arctic Shift output. Nothing dropped, nothing transformed. This is the canonical source-of-truth layer — every other layer can be rebuilt from this.

2 Hard filter deterministic

Input:data/raw/<sub>/*.jsonl Command:python ingest/filter_posts.py --sub <sub> + filter_comments.py --sub <sub> Output:data/filtered/<sub>/{posts,comments}_<after>_<before>.jsonl Schema:identical to raw — same fields per record, fewer rows
FilterPostsComments
Length200 ≤ len(title + selftext) ≤ 8000len(body) ≥ 75
Score≥ 1≥ 1
Authornot deleted, not [removed], not botnot deleted, not [removed], not bot
Keyword gate(none — assumed in-condition)at least one treatment-verb keyword ("tried", "helped", "worked for me", "switched to", "dose", etc.)
Pass rate (T1D run)49–61% (3 subs)16–19% (3 subs)
Why posts and comments diverge: posts are pre-filtered by being in a condition-relevant sub (we trust the sub label). Comments are 8.5× post volume, so the keyword gate kills ~80% of pure noise ("yeah me too", "this", "+1") cheaply, before paying LLM tokens.

4+5 LLM classify + extract Haiku 4.5

Input:data/filtered/<sub>/*.jsonl (one item at a time) Command:python extract/run_extraction.py --sub <sub> --date-range ... --output-name <extracted_dir> --max-cost 5.00 --yes Output:data/extracted/<extracted_dir>/<item_id>.jsonone file per item Model:claude-haiku-4-5-20251001 (pinned), prompt-cached, async concurrency 5, idempotent skip-if-exists Cost:~$0.0025/item, ~$3-5 per condition for 1-week multi-source

Extracted JSON schema — top-level fields (one per item):

FieldTypeMeaning
contains_treatment_infoboolTrue if poster (or someone they know) tried a treatment, even if framed as rant/question
stanceenumembodied | secondhand | hearsay | question | rant | meta | none
about_conditionboolWhether the post is actually about the seed condition
commercial_intentenumnone | weak | strong (POSTER's promotion of own product/service, NOT mentions)
skip_reasonstring?Set when contains_treatment_info=false
symptoms[]arraySymptoms the poster reports — independent of treatments
treatments[]arrayOne object per atomic treatment claimed

symptoms[] sub-schema: { name: string, duration: string }

treatments[] sub-schema — 12 fields per treatment:

FieldTypeNotes
interventionstring (free text)Canonicalization is downstream — never the LLM's job. The cluster key.
categoryenumsupplement | medication | diet | exercise | posture | behavioral | device | procedure | other
form, dose, frequency, timing, duration_triedstrings (free text)Granularity for drill-down only
outcomeenumhelped_a_lot | helped_some | no_effect | worsened | mixed | unclear
outcome_detailstringSide effects live here, not in symptoms[]
symptoms_targeted[]string[]Subset of post-level symptoms[].name. Drives the symptom-first frontend.
combined_with[]string[]Other interventions in the same protocol — preserves protocol signal across atomic units
caveatsstringFree text qualifications
Author identity is NOT passed to the LLM. Author scoring is a separate track (stage 6). This keeps the LLM call cheaper to re-run and lets author credibility be rebuilt nightly without LLM cost.

Current state on disk: 102,458 extractions across 26 conditions / 133,190 treatment mentions (one extraction can yield N mentions via the treatments[] array). Per-condition breakdown lives in dev_status.html and corpus.html.

6 Author authority track deterministic Arctic Shift API

Puller:scripts/pull_author_history.py — per-author Arctic Shift pulls, atomic .tmp → .jsonl per author, manifest at data/authors/_manifest.jsonl; circuit breaker (5fail→30min) handles the ~1.2k authors/IP rate cliff Features:scripts/compute_author_features.py — sqlite3 over data/glean.db (the author_history_items warehouse); full overwrite of data/authors/_features.json Persisted:data/glean.db author_history_items (raw history; legacy per-author JSONL shards archived to data/authors/legacy_jsonl_archive.zip) + data/authors/_features.json (the composite, keyed by author) Read by:extract/build_pipeline.py (stage 8 cluster weighting); manifest tracked via shared_inputs._features_json

Six feature families, deterministic via SQL over data/glean.db:

Feature familySourceStatus
Longevity (account_age_days, log-transformed with diminishing returns past ~2 yrs)Per-author Arctic Shift API pullbuilt
Activity & dispersion (total_posts, distinct_subs, sub_entropy)Per-author Arctic Shift API pullbuilt
Karma signal (median_score, pct_engaged ≥10, pct_at_floor ≤1) — downweight unengaged; do NOT upweight popularPer-author Arctic Shift API pullbuilt
Commercial ratio (commercial_link_ratio + promo_code_pattern_rate regex + aggregated commercial_intent from LLM stage)Author history domains + extraction stagebuilt
Condition-fit U-curve (1 cluster → 0.50 baseline; 3 tight d=1 clusters → 0.70 max boost; 8 scattered d=2 clusters → 0.20 max penalty)conditions/<name>_proximity_map.csv + 8×8 cluster distance matrix + author cross-sub postingbuilt
Data sufficiency (user_data_sufficiency ∈ [0,1] from total_posts) — sparse authors are downweighted, never filteredderived from total_postsbuilt

Composite score:

user_credibility = w1*longevity + w2*karma_signal + w3*(1 - commercial_ratio) + w4*condition_fit_curve + w5*data_sufficiency

Scores land in [0.3, 1.3]. Initial weights guessed; calibration via 40-60 hand-labeled high/medium/low authors is a remaining open question (PLAN.md Gap 3 follow-up). Wired into scripts/run_condition.py steps 4c (pull) + 4d (features) as of 2026-06-01 — every new condition reaches Phase B parity from the orchestrator alone.

Phase A vs Phase B: Phase A is the cheap fallback (commerce flag + simple baseline) for authors whose Arctic Shift history hasn't been pulled. Phase B is the full feature extraction. Corpus-wide Phase B coverage is currently ~99.4% across all 26 conditions; see dev_status.html for the live number.

Mentions table — the join in-memory rowset

Not a numbered pipeline stage — this is the in-memory fact-table assembly inside build_pipeline.py that feeds stages 7-10. Shown here because it's the load-bearing data structure the rest of the pipeline operates on.

The join: extractions{item_id → ext}source_items{item_id → src} ON item_id, then flatmap over ext.treatments[] (one mentions row per treatment-with-non-empty-intervention).
Built in:build_pipeline.py:208-242 Cardinality:extractions × avg_treatments_per_extraction filtered to contains_treatment_info=true and non-empty intervention Persisted?No. Lives only in memory during build_pipeline.py.

Mention row schema (18 fields) — this is effectively the wide fact table:

FieldFromNotes
interventionextracted.treatments[].interventionThe cluster key (after normalize)
item_idbothJoin key
authorsource_items[id].authorOnly path to author — extracted JSONs do not carry it
subredditsource_items[id].subredditFor distinct_subs, drill-down
titlesource_items[id].title"" for comments
body_snippetsource_items[id] selftext or body, truncated 200chDrill-down preview
stanceextracted.stance
categoryextracted.treatments[].category
outcome, outcome_detailextracted.treatments[]
dose, frequency, duration_triedextracted.treatments[]
combined_with[]extracted.treatments[]Protocol siblings
symptoms_targeted[]extracted.treatments[]
author_credibilitycredibility{author}Lookup, defaults 0.5 if author missing
Why this matters for cross-condition author analysis: the only thing tying author back to treatment claim is this in-memory join. The author column is added here, not in extraction. To answer "is the same person evangelizing keto in r/ibs and r/diabetes_t1?", you join the posts[] arrays in the three <condition>_pipeline_output.json files on author.

7 Treatment clustering embeddings (no generative LLM)

Input:distinct normalized intervention strings from mentions[] Algorithm:fastembed BAAI/bge-small-en-v1.5 → L2-normalize → agglomerative complete-link → cosine threshold 0.75 Built in:build_pipeline.py:cluster_interventions() Output:in-memory list of {aliases: set, mentions: list, canonical_name: str}
StepWhat happens
Normalizelowercase, strip punctuation
Dedupebuild unique-name list (preserves first-seen order for stable canonical naming)
Embedfastembed all unique names in one batch → 384-dim vectors
Pairwise simfull N×N cosine similarity matrix
Clustergreedy agglomerative complete-link: at each step, merge the pair with highest min pairwise sim across members; stop when no pair ≥ 0.75
Canonicalizeeach cluster's canonical_name = most-frequent normalized alias
Known limitation (carried): brand → generic translation isn't done here at clustering time — "imodium" and "loperamide" remain in separate clusters because their embeddings aren't close enough. We don't merge them at clustering because Reddit posters mostly say only the brand; the cluster boundaries are correct from the corpus's perspective. Brand→generic is handled at PubMed query time instead (see Stage 9), preserving cluster shape while fixing the literature lookup.
Performance (2026-04-30 fix): Replaced the O(N³) greedy merge loop with scipy.cluster.hierarchy.linkage(method='complete') + fcluster. NN-chain gives O(N²) memory and time. Runtime: IBS ~12 sec, Endo ~14 sec, T1D ~21 sec — bit-identical partitions verified against the pre-fix outputs across all three conditions.

8 Per-cluster aggregation deterministic

Input:clusters[] (each with mentions[]) + author_credibility{} Built in:build_pipeline.py:248-297 Output:output_clusters[] — the table that lands in pipeline_output.json

Cluster row schema (16 fields) — this is what surfaces in the dashboard:

FieldComputation
id1-indexed sequential
canonical_nameMost-frequent normalized alias in cluster
aliases[]Sorted list of all normalized intervention strings in cluster
categoryCounter(mention.category).most_common(1)
mention_countlen(mentions)
distinct_authorslen(set(mention.author))
distinct_subs[]sorted(set(mention.subreddit))
outcomes{}Counter(mention.outcome) — helped_a_lot, helped_some, no_effect, worsened, mixed, unclear
positive_ratio(helped_a_lot + helped_some) / (positive + negative + mixed)
cluster_weightsum(mention.author_credibility) — primary ranking signal in the prototype
cluster_breadthdistinct_authors × len(distinct_subs)
pubmed_count, evidence_level, novelty_scoreFilled in stage 9 (initially null)
posts[]List of 13-field drill-down rows: id, author, subreddit, title, body_snippet, stance, outcome, outcome_detail, dose, frequency, duration_tried, combined_with[], symptoms_targeted[]
pubmed_articles[]Filled in stage 9
Why posts[] embeds drill-down per cluster: the dashboard is a single static HTML file with all data inline. Embedding the post details in each cluster keeps the surface self-contained — no DB, no API. Trade-off: <condition>_pipeline_output.json is ~2 MB for 1 week of T1D data.

8b. Noise filter deterministic

Before clusters land in the persisted output, a post-aggregation filter drops two classes of low-signal clusters that were polluting the top-N novelty surface:

FilterRuleDrops
Generic catch-allsCanonical name in GENERIC_BLOCKLIST (14 phrases: "pain meds", "diet change", "various supplements", etc.) or matches ^u\d+ insulin$ (concentration metadata)~10–15 clusters/condition
Single-mention single-authordistinct_authors == 1 AND mention_count == 1 — one person said it once~190–250 clusters/condition
Total drop: ~44–48% of pre-filter clusters across all three conditions. After the filter, top novelty surfaces lead with real treatments (endo: "excision surgery" instead of "pain meds"). Pre-filter cluster IDs are NOT preserved — output IDs are renumbered after the drop. Live: 404→210 (IBS), 525→296 (Endo), 554→310 (T1D).

9 PubMed enrichment PubMed eUtils RxNorm (NIH)

Input:data/pipeline/<condition>_pipeline_output.json (clusters with null pubmed_count) Command:python extract/pubmed_enrich.py --condition <name> Brand→generic step:For category=="medication" clusters, lookup canonical_name via extract/rxnorm.py → NIH RxNorm REST API (cached in data/rxnorm_cache.json) + a hand-curated non-US fallback dict (motilium, novorapid, trurapi, visanne, slynd, slinda) For each cluster:NCBI eUtils esearch with (brand OR generic_1 OR generic_2 ...) AND (pubmed_terms OR-joined)esummary for top results Output:SAME file, in place — adds pubmed_count, evidence_level, novelty_score, pubmed_articles[], and generic_names[] (when expanded) per cluster
Why query both, not replace: brand-name papers (marketing, post-marketing safety) stay reachable while the larger generic literature opens up. T1D wins after the 2026-04-30 fix: mounjaro 0→45 hits, ozempic 2→108, lantus 60→652, jardiance 0→137, basaglar 4→644.

Evidence buckets (Gap 1 fix, 2026-04-30):

pubmed_countevidence_levelevidence_score
0none0.00
1–5weak0.35
6–20moderate0.70
21+strong0.95

Novelty score (the final ship axis):

novelty_score = cluster_weight × (1 - evidence_score)

High novelty = high community signal × low literature coverage. The "what's the crowd seeing that the literature isn't?" surface.

pubmed_articles[] per cluster: { pmid, title, year, journal } — top results stored for drill-down.

10 Final output + surfaces deterministic

10a. data/pipeline/<condition>_pipeline_output.json — the canonical persisted artifact

Top-level fieldTypeSource
condition, display_name, descriptionstringscondition config
sources[]arraycondition config (subreddit, extracted_dir, filtered_dir, date_range)
total_extractionsintcount of valid extracted JSONs
total_with_treatmentintcount where contains_treatment_info=true
total_mentionsintlen(mentions)
total_clustersintlen(clusters)
clusters[]arrayThe cluster rowset (16 fields/row, posts[] embedded), sorted by cluster_weight desc
evidence_summaryobjectCounter of evidence_level (added by stage 9)

10b. data/extracted/<condition>_summary.md — markdown report (summarize.py)

Aggregate stats: stance distribution, commercial intent, top treatments, top symptoms, category/outcome breakdowns. No new joins — pure rollup of extracted JSONs.

10c. data/pipeline/<condition>_results.html — the dashboard (build_ui.py)

Self-contained tabbed HTML. Reads pipeline_output.json + summary.md and inlines the data. 7 tabs: Project, Pipeline, Data Flow, Results Table, Results Charts, Browse, Project Files. (The in-page password gate was removed; the Cloudflare deploy bundle injects a soft passcode via gate.js at build time instead.)

Cross-condition view: where authors come together

To answer "is the same person posting across conditions?" you join the three <condition>_pipeline_output.json files on cluster.posts[].author:

authors_per_condition[author] = set() for condition in [ibs, endometriosis, type1_diabetes]: for cluster in load(condition).clusters: for post in cluster.posts: authors_per_condition[post.author].add(condition) # Authors who appear in ≥ 2 conditions are the cross-posters. cross_posters = {a: c for a, c in authors_per_condition.items() if len(c) >= 2}

Asymmetry to be aware of: most conditions ingest a single canonical subreddit (r/IBS, r/endometriosis, r/Asthma, …); a few have multi-source sources[] lists (Type 1 Diabetes pulls r/diabetes_t1 + r/Type1Diabetes + r/diabetes; Long COVID pulls multiple). A poster active in a sub the condition doesn't ingest is invisible to this condition's pipeline.

Implication: the cross-condition graph as visible here is bounded by the union of all sources[] across conditions. The Stage 6 author-authority track (now built) pulls each author's full cross-sub history via Arctic Shift, so the U-curve and condition-fit signals are not limited by which subs the conditions ingest. This in-pipeline view remains a useful narrow check for "did the same author surface in multiple condition extractions?" — a first cut visible without the per-author history.