Skip to content

Repository files navigation

Ebola Transmission-Chain Tracer

Contact-tracing triage for an Ebola outbreak response team, written in Jac.

Field teams have far more contacts to monitor than capacity to reach them. This tool takes a newly confirmed case, traverses the contact network to find everyone exposed, ranks them by transmission risk, and then decides who gets a home visit, who gets a phone check, and who waits, given a hard daily capacity limit.

Decision-support prototype. Runs on synthetic contact networks calibrated by real aggregate outbreak data from INRB/INSP. Person-level contact data is confidential by design, so real deployment would mean running this inside a health ministry against their own linelist, with field validation and epidemiologist input.


What is real and what is synthetic

Synthetic: every Person, every ExposureEvent, and every attendance edge. Who exposed whom is confidential patient data held inside health ministries. It is not public and never will be.

Real: the aggregate shape the synthetic population is calibrated against — per-zone confirmed case counts, deaths, and contact-tracing indicators from INRB-UMIE/BDBV2026-Data, the DRC response data repo run by INRB and INSP with WHO and Africa CDC collaborating.

Every run prints which is which, plus the build date of the real data.


Quick start

python3.14 -m venv .venv && . .venv/bin/activate
pip install 'jaclang==0.16.7' 'byllm==0.6.19'

# Web app (what the sandbox deploys)
jac start                          # serves the animated simulation at http://localhost:8000

# Terminal CLI (offline demo path)
./tracer --refresh                 # fetch real aggregate data into ./data_cache/ (once)
./tracer                           # trace + triage the first confirmed case
./tracer --nth 1                   # a second case: watch it skip already-traced contacts
./tracer --reset                   # wipe the synthetic graph and triage memory

After the initial --refresh, the demo never touches the network for data. Everything reads from ./data_cache/.

LLM configuration

The agent layer uses by llm(). The provider is auto-detected from whichever API key is in your environment — export a key and it is used, with no code or config edit:

Key Model used
ANTHROPIC_API_KEY anthropic/claude-sonnet-4-6
DEEPSEEK_API_KEY deepseek/deepseek-chat
OPENAI_API_KEY openai/gpt-4o
GEMINI_API_KEY gemini/gemini-2.0-flash

First match in that order wins, so Anthropic takes precedence when several keys are set. Reorder PROVIDERS in agent.jac to change it, or force one model with --model anthropic/claude-opus-4-1. Every run prints which model it resolved to, so the banner — not jac.toml — is the authority on what actually ran.

Keys are read from the environment or a gitignored .env, never from source or jac.toml:

export ANTHROPIC_API_KEY=...        # or add a line to .env

Anthropic models are worth preferring where available: byLLM enables prompt caching for model names beginning anthropic/, which cuts input tokens substantially across the agent's ReAct iterations. That is why the model string keeps its anthropic/ prefix rather than being shortened.

Note that deepseek rejects the response_format JSON-schema parameter. The agent works on it anyway because tools=[...] makes byLLM carry the typed return on a function call instead — see the comment above triage in agent.jac.

No key, or no network? The tool degrades instead of crashing: --no-llm forces it, and any LLM failure falls back automatically to a deterministic rule-based triage with the same return type. The output always states which engine produced the assignments.

Options

Flag Meaning
--case BUN-000 trace a specific index case
--nth N use the Nth confirmed case instead
--capacity N daily home-visit capacity (default 12)
--top N how many ranked contacts to hand the agent (default 25)
--refresh re-fetch real data (the only networked data path)
--no-llm skip by llm(), use the deterministic fallback
--model NAME force a model instead of auto-detecting from the available key
--reset wipe the synthetic graph and triage memory

The web app

jac start serves an animated simulation of the whole network at /. It is written in Jac end to end - .cl.jac client components compile to React, and the force-directed physics and canvas rendering in components/Network.cl.jac are Jac too, not JavaScript.

Clicking Drop in a confirmed case runs the real pipeline server-side and animates it in seven phases: the case flares, the walker travels out along attendance edges, exposure events light up, every other attendee becomes a traced contact, risk scores land, then the agent's decisions colour in one at a time with their justifications. Click again and the second case visibly skips everyone already handled.

Nothing on screen is faked: every number comes from the same walkers, scoring, and by llm() triage the CLI uses, exposed as walker:pub endpoints in endpoints.sv.jac.

File Role
main.jac web entry - server endpoints + client mount (jac start serves this)
cli.jac terminal CLI (./tracer)
endpoints.sv.jac GraphSnapshot, RunTrace, ResetSim REST walkers
frontend.cl.jac + frontend.impl.jac UI shell and animation choreography
components/Network.cl.jac canvas force simulation
components/Legend.cl.jac legend + provenance banner

Deploying to a sandbox

This is a standard Jac project (kind = "fullstack"), so a Jac sandbox host deploys it directly: import the repo, add ANTHROPIC_API_KEY (or any supported provider key) in the project's environment settings, run a preview, then deploy. The key is never committed - .env is gitignored and the provider is auto-detected at runtime.

If the host cannot reach the LLM provider, the deployed app still runs: it falls back to the deterministic rule-based triage and says so on screen.

How it works

Layer 1  REAL DATA (fetch.py)          per-zone cases, deaths, contact-tracing indicators
   |                                   cached to ./data_cache/, never re-fetched at demo time
   v  calibrates
Layer 2  SYNTHETIC GRAPH (model.jac, generate.jac)
   |                                   Person nodes, ExposureEvent nodes, Attended edges
   v  traversed by
Layer 3  WALKER (trace.jac)            TraceContacts: 2-hop traversal + risk scoring
   |
   v  ranked list
Layer 4  AGENT LOOP (agent.jac)        by llm() triage under capacity, with memory + re-plan

The graph is the point

There are no Person-to-Person edges. Exposure always routes through an ExposureEvent node:

Person(confirmed) --Attended--> ExposureEvent --Attended--> Person(others)

One funeral links forty people in a single hop, and risk can be weighted by setting. This is the design decision that makes the project graph-native rather than a table with extra steps.

Risk scoring

risk = setting_weight * recency_factor * path_multiplier * unreached_boost
  • setting_weight — funeral 1.0, household 0.9, clinic 0.7, transport 0.4. Burial practices and close household care are the documented primary transmission routes for Ebola. Tunable in model.jac.
  • recency_factorclamp(1 - days_since / 21, 0, 1). Ebola's incubation window is 21 days.
  • path_multiplier1 + 0.5 * (distinct_paths - 1), where distinct_paths counts the distinct exposure events linking that person to any confirmed case. Someone reachable through three separate events is a much bigger risk than someone reachable through one.
  • unreached_boost — 1.3x when last_contacted is null or older than 3 days. These are the people falling through the follow-up gap.

path_multiplier is the flagship computation. It is painful in SQL or pandas and natural as a graph traversal, and it is broken out as its own column in the output so you can see it working. A regression test asserts a two-path household contact outranks a one-path funeral contact despite the lower setting weight.

The agent loop

by llm() does the judgment, not a hard cutoff at rank 12. It is told to weigh real tradeoffs — a contact in a zone with a poor follow-up rate is less likely to be reached any other way; a contact exposed 19 days ago is nearly out of the incubation window. It has a graph-backed tool, exposure_detail, that pulls a contact's full exposure history when the ranked summary is not enough to decide.

Memory and re-planning ride on the graph. Person.triaged is durable because the nodes are reachable from root, so a second case does not re-queue people already handled — it reports them as already covered and re-plans against remaining capacity.


The follow-up gap

The spec this was built to assumed the source data carries a contact follow-up rate. It does not — there is no percentage metric anywhere across the 131 published outputs. So the rate is derived, in three tiers, and every zone's output states which tier it came from:

  1. Sitrep narrative — the prose column of public_health_response__epidemiological_monitoring_en carries strings like follow-up: 3 837 (2 032 seen in 24h, a real dated per-zone rate. Best signal, but only a few zones report it this way.
  2. contacts_seen / cumulative_contacts_traced — best same-date ratio per zone. Zero readings are non-reporting days rather than true zeros, so the best observed ratio is used.
  3. National default — the mean of everything observed above, printed as an assumption.

That gap is the whole point. If every contact were perfectly traced, the tool would have nothing to do. The unreached fraction is exactly the scarcity being triaged under.


Data notes and caveats

  • The contact-tracing series is stale. Case counts run to 2026-07-23; contact-tracing indicators stop at 2026-05-24/30, roughly two months behind. Both dates are printed on every run.
  • build/long/*.csv have no header row, carry a UTF-8 BOM, and use CRLF. Columns are positional: nom, date, value.
  • The value column contains ND ("non disponible") and at least one malformed row. These are rejected and counted, not silently coerced to zero — the count is printed on every run.
  • national_* metrics are replicated onto all 519 zones upstream; summing them across zones gives garbage. They are not used.
  • Rows whose zone is DRC (national roll-up) or NA (a genuine malformed zone name upstream, carrying 74 cases) are excluded.
  • Zone-name spellings differ between upstream files (Mongbwalu / Mongbalu).

The outbreak numbers here are preliminary and are not validated or current.


Tests

jac test model.jac      # schema connects; no Person-to-Person edges; recency math
jac test trace.jac      # 2-hop traversal terminates; path_multiplier actually differentiates
jac test agent.jac      # rule fallback never exceeds remaining capacity

Data source, license, and citation

Data from INRB-UMIE/BDBV2026-Data — INRB Kinshasa / INOHA and INSP, with Africa CDC, WHO, Northeastern University, and the University of Oxford. Repository code is MIT licensed; third-party data remains under its original licenses.

The maintainers ask to be contacted for pre-publication use of the epidemiological data:

Please note that the epidemiological data presented here is based on work in progress and should be considered preliminary. Our analyses are ongoing, and a publication communicating our findings is in preparation. [...] If you intend to use the epidemiological data prior to our publication, or have other enquiries, please contact Prof. Placide Mbala-Kingebeni (INRB, DRC), Prof. Dav Ebengo (INRB, DRC), and Pierre Akilimali (INSP).

Cite the repository via its Zenodo DOI and the accompanying Lancet Infectious Diseases paper by Mbulayi et al.

INSP sitrep data is reusable with attribution to INSP and citation of the specific report number and date; confirm distribution terms with INSP before external republication.


Limitations

This is a hackathon prototype, not a deployed tool.

  • The contact network is generated. No conclusion about any real person can be drawn from it.
  • Risk weights are plausible defaults from the Ebola transmission literature, not fitted parameters. They have had no field validation and no epidemiologist review.
  • The follow-up rate is derived from thin and partly stale upstream coverage (see above).
  • Real deployment would mean running this inside a health ministry against their own linelist.

© 2026 Arunachalam Kasi. All rights reserved. Public for review purposes. No license granted for reuse or redistribution.

About

Graph-native Ebola contact-tracing triage under capacity constraints, written in Jac. Synthetic contact network calibrated by real INRB/INSP aggregate outbreak data.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages