The schedule of activities is the densest page in a protocol: a matrix with visits across the top, procedures down the side, and an X in every cell where the two intersect. Turning it into a day-by-day schedule is arithmetic, and the arithmetic has exactly one place it goes wrong.
The shape of the schedule table
A schedule of activities — sometimes headed schedule of assessments or study flow chart — is a wide table whose column headers are visits and whose row labels are procedures. It is the only table in the document where the column headers are the data, and it presents three extraction problems at once.
It is wider than the page, so it is printed in landscape, split across two facing pages, or continued with the row labels repeated. A continuation whose row labels are repeated but whose column headers are new is easy to misread as a second table. It has merged header cells: a top row grouping columns into Screening, Treatment and Follow-up, with a second row giving the actual visit numbers, so the header is two rows deep and a parser reading one row gets either the phases or the visits but not the mapping between them — the general treatment is merged-cell table extraction, and the continuation case is column misalignment across pages. And its cells are usually a bare X, so the information content is entirely positional — the value means nothing without both its row label and its column header.
SCREENING | TREATMENT (21-day cycles) | EOT
V1 | C1D1 C1D8 C1D15 | C2D1 C2D8 C2D15 |
(-28 to -1)| (+/-0) (+/-2) (+/-2)| (+/-3) (+/-2) (+/-2)|
Informed consent X | | |
Study drug administration | X X | X X |
Vital signs X | X X X | X X X |
Haematology X | X X | X X |
Tumour assessment X | | X |
Extract this as a list of cells — visit identifier, procedure, present or absent, plus any footnote marker — rather than as a nested object. The footnote markers matter: a superscript letter on an X frequently says “only if clinically indicated” or “cycle 1 only”, which changes the cell from an instruction into a conditional one, and the footnote text is at the bottom of the table, several hundred characters away.
Cycle and day notation
The column headers use a compressed notation that has to be parsed rather than matched:
- C1D1 — cycle 1, day 1. Also written
Cycle 1 Day 1,C1 D1, or as a bareDay 1in a single-cycle protocol. - Day -28 to -1 — screening, expressed as a range of days relative to the anchor. Negative days are before the anchor and the range is a permitted window, not a schedule.
- +/- 3 days — the visit window. A visit scheduled for day 22 with a window of three days may occur on days 19 to 25, and the window differs by visit type in the table above.
- EOT, EOS, FU — end of treatment, end of study, follow-up. These are event-anchored rather than day-anchored: end of treatment happens thirty days after the last dose, whenever that is, so no absolute day can be computed for them at all.
The critical convention is that there is no Day 0. Day 1 is the first day of the cycle, the day the first dose is given, and the day before it is Day -1. This is not a formatting quirk; it is the numbering the protocol’s own text uses everywhere, and any code that treats cycle day as a zero-based offset will be one day out on every calculation it performs.
How the dose itself is expressed
The schedule tells you when. The dose is stated elsewhere, in the treatment section, and it comes in three incompatible forms:
- A flat dose — a fixed amount for every participant. Directly extractable.
- Weight-based — expressed per kilogram, so the actual dose is a function of a measurement taken at a specified visit, and the protocol states when weight is re-measured and whether the dose is recalculated.
- Body-surface-area-based — expressed per square metre, common in oncology. The body surface area is itself derived from height and weight by a named formula, and the protocol specifies which formula, because they do not agree with each other.
For the last two the extracted dose is a formula, not a number, and the schema has to be able to say so: a dose_basis of flat, per_kg or per_bsa, a numeric coefficient, a unit, and a reference to where the input measurement comes from. Emitting a number for a per-square-metre dose requires a patient, and at extraction time there is no patient.
Capping and rounding rules travel with these. A protocol commonly caps a body-surface-area calculation at some maximum, or specifies rounding to the nearest vial size, and both are stated in prose next to the formula rather than in the table. An extraction that captures the coefficient and drops the cap has produced a rule that diverges from the protocol only at the extremes — which is the hardest kind of error to notice.
Expanding to a calendar
Given a cycle length, a set of within-cycle days and a cycle count, the expansion is short. It is worth writing out because of where the off-by-one lives:
cycle_length = 21 # days
dosing_days = [1, 8] # within-cycle day numbers, 1-based
cycles = 6
def study_day(cycle, day_in_cycle, cycle_length):
# Cycle 1 Day 1 is study day 1. There is no day 0.
return (cycle - 1) * cycle_length + day_in_cycle
for c in range(1, cycles + 1):
for d in dosing_days:
print(f"C{c}D{d}", "->", "study day", study_day(c, d, 21))
# C1D1 -> study day 1 C2D1 -> study day 22
# C1D8 -> study day 8 C2D8 -> study day 29
# C3D1 -> study day 43
Cycle 2 Day 1 is study day 22, not day 21. The last day of cycle 1 is day 21 and the next cycle starts the following day, so the multiplier is (cycle - 1) and the within-cycle day is added rather than offset. Written as cycle * cycle_length + day it produces day 43 for C2D1 — an error that grows with every cycle and that no schema validation will catch, because 43 is a perfectly valid day number.
Convert to calendar dates only at the point where a real start date exists, and carry the visit window through as a permitted interval rather than collapsing it to a point. A schedule that says “15 April” when the protocol says “day 22, plus or minus three days” has thrown away the tolerance that made the schedule operable.
Two expansions cannot be performed at all and should be modelled as unresolved rather than approximated. Event-anchored visits — end of treatment, unscheduled visits, follow-up after progression — have no computable day. And cycle counts are frequently open-ended (“until progression or unacceptable toxicity”), so the number of cycles is not a number. Give the schedule a cycles_max that may be null and an explicit stopping-condition string.
What is deliberately not in the table
The schedule of activities is a summary and the protocol says so, usually in a sentence directly beneath it stating that the text governs in case of conflict. Three things live outside it and are routinely, wrongly, assumed to be inside:
- Dose modification rules. What happens to the dose after a toxicity — reduction levels, delays, permanent discontinuation — is its own section with its own tables. The schedule shows the planned dose only, and a system that treats it as the actual dosing rule will be wrong for every participant who had a modification.
- Premedication and supportive care. Often required and often stated only in prose, sometimes with its own timing relative to the study drug (“30 to 60 minutes prior”), which is a within-day offset the table has no column for.
- The window on the anchor itself. Randomisation to first dose usually has a permitted interval, so study day 1 is not necessarily the randomisation date, and conflating them shifts the whole calendar.
The general lesson is that the table is an index into the protocol rather than a specification of it, so every extracted cell should carry a pointer back to the section that governs it. That provenance is the same field set the eligibility criteria page argues for, and for the same reason: on a corpus of protocols, the question is never only “what does the schedule say” but “which version of which document said it”.
A protocol is a long document and this is one of the workloads where that is the operational constraint rather than an aside: a two-hundred-page PDF exceeds what fits comfortably in one request on some models and not others, the section-location pass and the table-extraction pass want different models, and providers differ in how strictly they honour a structured output schema for a deeply nested object like a schedule. Multigrid gives you one API and one key across providers so routing a long-context pass to one model and a cheap classification pass to another is a routing rule rather than two integrations, with per-request cost tracking across both.
Top comments (0)