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.
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.
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 memoryAfter the initial --refresh, the demo never touches the network for data. Everything reads
from ./data_cache/.
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 .envAnthropic 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.
| 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 |
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 |
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.
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
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 = 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_factor —
clamp(1 - days_since / 21, 0, 1). Ebola's incubation window is 21 days. - path_multiplier —
1 + 0.5 * (distinct_paths - 1), wheredistinct_pathscounts 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_contactedis 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.
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 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:
- Sitrep narrative — the prose column of
public_health_response__epidemiological_monitoring_encarries strings likefollow-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. 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.- 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.
- 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/*.csvhave 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) orNA(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.
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 capacityData 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.
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.