swereg 27.3.6
Documentation
-
vignette("tte-methods")andvignette("tte-nomenclature")say which year a boundary week belongs to: an event at exactly week 52 is in the first year, so in whole weeks the years are weeks 1 to 52 and weeks 53 to 104.
swereg 27.3.4
Bug fixes
-
The CONSORT images and Love plots name things in the reader’s words. The CONSORT title no longer prints the enrollment id. A washout criterion prints the
nameof its spec entry, and an exclusion step drops a leading “No” from that name. The generated rule text, which names the source variable, is the fallback when the entry has no name. A Love plot row prints thenameof its confounder.
swereg 27.3.3
New features
-
tables.xlsxandfeasibility.xlsxhold a Protocol adherence sheet. For each comparison and arm,N (ITT)counts the person-trials still observed andN (PP)those still on protocol. The sheet shows weeks 13 and 26 and every multiple of 52. The counts are unweighted and come from the s1 raw file, before any outcome. -
$get_adherence()returns the weekly counts behind the sheet. s3 stores them asadherencein each enrollment result.$recompute_baselines()recomputes a result stored without them.$export_tables()does so too when the raw file of the enrollment is in the output directory.
swereg 27.3.2
Breaking changes
-
A follow-up that is not a whole number of years takes the minimum-events decision of the next whole year. A 26-week estimate is reported exactly when the 52-week estimate of the same comparison and outcome passes the rule. A 78-week estimate follows the 104-week estimate in the same way. The rule no longer judges a partial year on its own. Rerun
$s3_analyze()for a plan with a part-year horizon. - The ETT grid adds the whole year when the spec has no follow-up of that length for a comparison. s3 computes only the verdict of the rule for this horizon-only ETT, and only the Minimum events sheets show it. s1 enrolls the comparison through that whole year, because the verdict needs data to the end of it. Reported outputs still stop at the follow-ups of the spec.
-
$export(),$export_tables()and$print_target_checklist()stop on a stale result of a follow-up that is not a whole number of years. They stop when the stored result does not hold the current decision of its source ETT, the ETT at the next whole year. The message names$s3_analyze(). -
The generated methods text of every study changes one sentence.
tteplan_osf_text()no longer says “The last year ends at the horizon, so it can be shorter.” TARGET item 6h no longer says “and the last year ended at the horizon”. Both now state the rule for a horizon that is not a whole number of years.
New features
-
A
follow_upentry MAY setonly_enrollments, a list of enrollment ids. The horizon then applies only to those comparisons. A single id is a list of one.tteplan_read_spec()andtteplan_check_spec()refuse an empty list, a repeated id, an id that names no enrollment and a value that is not a string.$print_spec_summary(), the Study Specification sheet,tteplan_osf_text(), TARGET item 6d and the protocol table name the enrollments of each restricted horizon.$reload_spec()treats a change of the key as structural. -
feasibility_follow_up_weeksis compared with the horizons of each enrollment. The feasibility horizon is added to an enrollment when it is longer than every horizon of that enrollment. In 27.3.1 it was compared with every horizon of the spec. A spec withoutonly_enrollmentsgets the grid of 27.3.1. -
tteplan_osf_text()names each part-year horizon and its whole year, for example “The 6-month estimate is reported when the 1-year estimate passes the rule.” Withmin_expected_events_per_arm_per_year: 0the text keeps its single sentence that the rule withholds no estimate, and it adds no part-year sentence.
swereg 27.3.1
New features
-
An enrollment MAY set
minimum_events_rule_exempt: true. The minimum-events rule then withholds none of its estimates, and s3 still stores the verdict of the rule. The Minimum events sheets show that verdict with(exempt)after the comparison, and the generated methods text names the exempt comparison. A changed key stops the writers until$s3_analyze()reruns; s1 and s2 output stays valid. -
The optional spec key
feasibility_follow_up_weeksadds a feasibility horizon. When it is longer than everyfollow_up, each enrollment and outcome gets one more ETT of that length. s3 computes only the minimum-events rule for it, and only the two Minimum events sheets show it.$reload_spec()treats a change of the key as structural.$print_spec_summary()lists the feasibility horizon under Follow-up, and the exempt enrollments under Minimum-events rule. -
The two Minimum events sheets show one column per whole follow-up year, from 1 to the longest follow-up, feasibility horizon included. A year without an ETT of its own reads the per-year decision of the shortest longer ETT, which every horizon record now holds as
by_year. A feasibility record from 27.3.0 stops the sheets until$s3_feasibility()reruns. -
$export_tables()also writesfeasibility.xlsxbesidetables.xlsx, with the sheets of$s3_feasibility(), from the s3 results.
swereg 27.3.0
Breaking changes
-
The intention-to-treat estimate follows the per-protocol decision of the minimum-events rule. It is reported exactly when the per-protocol estimate of the same ETT is, to the same reportable horizon. Before, each estimand was judged on its own weights. The horizon record of the intention-to-treat estimand holds
decided_by = "pp".$export(),$export_tables()and$print_target_checklist()stop on results stored before 27.3.0, and the message names$s3_analyze(). Rerun s3 for each project; s1 and s2 output stays valid.
New features
-
Two wide Minimum events sheets replace the long one, in both
tables.xlsxandfeasibility.xlsx. Each has one row per outcome and comparison, outcome first, and one column per follow-up horizon, for the per-protocol estimand.Minimum events (Y or -)shows Y on a blue fill when the estimate is reported and - on a red fill when it is withheld.Minimum events (#)shows the largest cut, of 1, 2, 3, 4, 5, 10 and 20 expected events per arm per year, at which the estimate is still reported, so a different cut can be read without a rerun. Neither sheet shows an event count, a rate or expected events.$get_horizon_windows()still returns the year-by-year detail. - The Minimum events sheets stop when two ETTs share an outcome name, an enrollment and a follow-up, because they would share one cell.
-
Every horizon record holds
max_passing_cut, the largest cut of that grid at which the estimand passes. -
Sheet order. Both Minimum events sheets follow the Rare conditions sheet.
feasibility.xlsxholds Study Specification, Rare conditions, the two Minimum events sheets, Attrition and Table 1, in that order. -
tteplan_osf_text()says that the intention-to-treat estimate follows the per-protocol decision.
swereg 27.2.17
Bug fixes
-
tteplan_osf_text()says that each enrollment period opens one trial in each comparison, when the spec has more than one comparison. It said “one trial”, which is false for a study with several comparisons. -
The minimum-events text of
tteplan_osf_text()says that the survival curve is still reported for a withheld estimand, asvignette("tte-methods")states.
swereg 27.2.16
Bug fixes
-
tteplan_osf_text()states a criterion that several comparisons share once. Under ‘Data inclusion and exclusion’, a criterion held by some comparisons but not all was repeated once per comparison. It is now one item, for example “In comparison 1, 2 and 3 only: …”. A criterion held by one comparison still names that comparison and its arms.
swereg 27.2.15
Breaking changes
-
The spec key
horizon_rule: {min_expected_events: k}is now the flat keymin_expected_events_per_arm_per_year: k, andsparse_ruleis nowrare_condition_rule.tteplan_read_spec()andtteplan_check_spec()refuse the old keys, and the message names the new key. The rules and their numbers do not change. - Every spec needs a new version for the renamed keys. Copy the spec to a new version and rename the keys there. Never edit a released spec version.
-
A plan MUST hold s3 results from 27.2.14 or later to export. For results stored before 27.2.14, rerun the full
$s3_analyze(). Results that 27.2.14 stored export correctly under 27.2.15: a withheld estimate still readsNE.tteplan_load()renames the two rule keys of the stored spec, so the s1 and s2 output stays valid. -
The horizon rule is now the minimum-events rule. The workbook sheet
Expected eventsis nowMinimum events, andSparse conditionsis nowRare conditions. The status of a withheld estimand starts with"below minimum-events rule",$print_spec_summary()printsMinimum-events rule:, and the columnmin_expected_eventsof$get_horizon_windows()is nowmin_expected_events_per_arm_per_year.
New features
-
$s3_feasibility()applies the minimum-events rule after s2 and fits no outcome model. Its decision equals the decision of$s3_analyze(). It writesfeasibility.xlsxandplan$feasibility, and neither holds an event count, a rate or expected events after time zero. The Attrition sheet keeps the eligibility counts by arm, including exclusions for an outcome event before time zero. -
tte_s0_flags()reads the--checkand--feasibilityflags of a project’ss0_init.R. It stops on any other argument. swereg now imports optparse. -
$slurm_job()andtte_stage()accept the stage"feasibility". The job runs$s3_feasibility(), then saves the plan.
Documentation
- The OSF Inference criteria field, TARGET item 6h, the protocol table and
vignette("tte-methods")say that the expected events are computed under the null.
swereg 27.2.14
Breaking changes
-
Every specification MUST declare
horizon_rule: {min_expected_events: <number>}.tteplan_read_spec()refuses a specification without it.$s3_analyze(),$export_tables(),$get_protocol_table()and$print_target_checklist()stop on a plan whose spec lacks it. The house default is 5. -
$s3_analyze(rd_min_persons_at_risk = )and$risk_difference(min_persons_at_risk = )are removed. The horizon rule replaces the persons-at-risk rule of 27.2.7, and the status"too few at risk at horizon"no longer occurs. -
Rerun
$s3_analyze()on a plan that stored its results before 27.2.14. Those results hold no horizon decision, and$export(),$export_tables()and$print_target_checklist()stop on them.
New features
-
$s3_analyze()applies the horizon rule to each estimand of each ETT. An arm’s expected events in a 52-week follow-up year are its weighted person-years times the pooled weighted rate of both arms. When either arm falls belowmin_expected_eventsin any year up to the horizon, s3 withholds the estimates of that estimand. These are the rate ratio, risk difference, number needed to treat, subgroup ratios and interaction tests. Events, person-years, rates and the survival curve stay. -
The workbook has an
Expected eventssheet. It gives each follow-up year of each ETT and estimand, with the expected and observed events of each arm, the decision and the reportable horizon. - A withheld rate ratio, risk difference or number needed to treat reads
NEon every sheet and in the forest figure. - The survival figure stops each curve at the reportable horizon. A grey band labelled “Not estimable” covers the rest of follow-up.
-
$export(),$export_tables()and$print_target_checklist()stop when a stored result has no horizon decision, or a decision made with anotherhorizon_rule. The message names$s3_analyze(). The writers comparemin_expected_eventswithidentical(), so a difference of any size stops them. -
tteplan_osf_text()writes a newInference criteriafield that states the horizon rule. TARGET item 6h and the Analysis plan row of the target trial protocol table state it too.
Bug fixes
- The minimum detectable effect uses the pooled weighted rate of both arms. It used the unweighted rate of the comparator arm.
-
tteplan_osf_text()says that the outcome model holds no confounder. The earlier wording ruled out every covariate, which was false: the model holds the trial and the time since time zero.
Documentation
-
vignette("tte-methods")states the horizon rule in sections 1.6, 1.8.5 and 1.8.6 and in the manuscript methods. The text says that the rule depends on the observed events and does not guarantee precision.
swereg 27.2.13
Documentation
-
The code list of
tteplan_osf_text()is a bulleted list, not a table. The OSF form takes text and bullet points. Each item gives the name, its use, the codes or “Derived variable, no codes”, the window and the categories.
swereg 27.2.12
Documentation
-
tteplan_osf_text()no longer says how the tolerance relates to gap bridging in the treatment classification. The treatment classification is the study’s own, and its rules belong in its documentation, not in a sentence swereg writes into every registration.
swereg 27.2.11
Documentation
-
tteplan_osf_text()writes four sentences more plainly. The comparator draw “creates no matched sets, so no analysis conditions on matched sets”; assignment “uses only the weeks” in which a person is eligible and holds a compared strategy; the tolerance “applies to the weekly treatment value, after any bridging of gaps between dispensings by the treatment classification”; and the code-list note says that a row marked derived variable has no codes. No statement changed its meaning.
swereg 27.2.10
New features
-
$export_tables()also writesresults.qs2next to the workbook. A manuscript reads it, so an analysis project no longer builds its own results object. It is a named list:estimates,curves,baselinesandattritionfrom the four accessors,enrollmentswith one row per enrollment,spec_version,swereg_version,swereg_version_computing,s1_run_idands2_run_id. Theenrollmentstable holds the enrollment id and name, the intervention and comparator names, and the per-protocol person-trials of each arm. No table holds a person or person-trial identifier, and no table holds a row per person or per person-trial. An existing plan gets the file at its next s4 export.
swereg 27.2.9
New features
-
tteplan_osf_text()writes the text of the OSF Preregistration fields that the specification determines. It returns one Markdown text for each of these fields: Study design, Sample size, Measured variables (exposures, outcomes and baseline covariates), Statistical models, Transformations, Data inclusion and exclusion, Missing data, Other planned analysis, and a code list. Every exclusion criterion, covariate and outcome of the specification appears, and each fact sits in one field. The rare-condition rule sits under Data inclusion and exclusion. The function reads the specification and the code registry only, so it runs before s0, astteplan_excel_spec_summary()does. It prints thenameanddescriptionfields as the specification writes them, so write those for a reader of the registration.
swereg 27.2.8
New features
-
RegistryStudy$set_batches()sets the batch composition directly, and an ID MAY be in more than one batch.$save_rawbatch()writes the rows of that ID into each batch that holds it, so the population and summary counts count that person once per batch. -
$set_ids()and$set_batches()stop when the composition changes while rawbatch or skeleton files are on disk. A study saved by 27.2.6 or earlier has no recorded composition, so it records one and continues. -
RegistryStudy$set_matched_sets()sets the batch composition from a roster of matched sets. Each set stays in one batch. Each membership gets its ownskeleton_idand its own copy of the person’s rawbatch rows. A control in two sets then gets its codes in both. -
$save_rawbatch()writes the roster rows of each batch as the rawbatch group"roster", and phase functions read them asbatch_data[["roster"]]. The roster is part of the recorded composition, so a changed roster stops while rawbatch or skeleton files are on disk.
swereg 27.2.7
Bug fixes
-
No risk difference is reported at a stop time where either arm has fewer than 10 persons at risk. The point estimate, the interval and the number needed to treat are all
NA, andinterval_statusreads"too few at risk at horizon". With about 3 persons at risk in one arm, the bootstrap interval covered the truth in 161 of 194 simulated datasets.$s3_analyze(rd_min_persons_at_risk = )and$risk_difference(min_persons_at_risk = )set the threshold. The results sheets, the Summary sheet and the forest figure print “not estimable” with the reason. The incidence rate ratio is unchanged. An existing plan gets the rule afterplan$s3_analyze(ett_ids = )on the trials it affects. - A CONSORT exclusion line gives the person-trials first, then the persons the step excluded entirely. An example is “No week in either treatment arm (n = 48,122,520 person-trials; 1 person excluded entirely)”. A step counts a person only when it removes her last person-trial, so “n = 1 persons / 48,122,520 person-trials” misled.
New features
-
Table 1 shows a continuous variable as median (Q1, Q3) and range (min, max), beside its mean (SD). The two rows follow the mean (SD) row, unweighted and weighted. The weighted median is the first distinct value at which the cumulative normalised weight reaches 0.5. Where that weight equals 0.5, the median is the mean of that value and the next one. Q1 and Q3 use 0.25 and 0.75, and equal weights give
stats::median()andquantile(type = 2). A plan saved before this version shows the rows afterplan$recompute_baselines(force = TRUE).
Documentation
-
vignette("tte-methods")andvignette("tte-methodology")cite Cole and Hernán (2008) for the truncation at the 1st and 99th percentiles. Both cited Danaei et al. (2013), who truncated the stabilised weights at a maximum of 10.
swereg 27.2.6
Bug fixes
-
tteplan_read_spec()stops when two or more enrollments carry the sameid(#58). The message names each repeatedidand the positions of its enrollments.tteplan_check_spec()reported the repeat, buttteplan_read_spec()accepted it, and$reload_spec()and the Summary sheet then read only the first enrollment with thatid. -
Each enrollment
idMUST be one non-empty character string or one number.tteplan_read_spec()stops on a missingid, a sequence, a mapping, or an empty or whitespace-only string, and names the enrollment positions.tteplan_check_spec()reports each as the new kindinvalid_enrollment_id. Before, both functions accepted two enrollments withid: [foo, bar], because the repeat check skipped a sequence.
Documentation
- The spec example of
vignette("tte-workflow")setsfamily, and the text points tovignette("tte-spec-schema")for its rules. -
The spec example of
vignette("tte-workflow")setsstudy$implementation$version. Without it, the example saved asspec_v001.yamlfailedtteplan_check_spec()with aversion_mismatchrow. -
vignette("tte-spec-schema")names the three functions that read a specification throughtteplan_read_spec(). It namedtteplan_from_spec(), which does not exist, and left outtteplan_excel_spec_summary().
swereg 27.2.5
New features
-
$export_tables()writes a “Summary” sheet after Provenance, with one row per ETT. It shows the events and the incidence rate ratio of each estimand, the per-protocol share of the intention-to-treat person-years, and the per-protocol risk difference. Each per-protocol value uses the truncated weights, and each estimate matches the “ITT results” or “PP results” sheet. The header is row 1 and carries an autofilter. -
An enrollment MAY set
family, a display label that the Summary sheet shows in aFamilycolumn. It MUST be one non-empty string, and when one enrollment sets it, every enrollment MUST set it.tteplan_check_spec()reports a badfamilyas the new kindenrollment_family, andtteplan_read_spec()stops on it.$reload_spec()applies a newfamilyas a label change.
swereg 27.2.4
Bug fixes
-
$get_estimates(),$get_curves()and$get_subgroups()label each intention-to-treat rowweights = "truncated". The intention-to-treat estimates use the truncated baseline weightipw_trunc, but the accessors labelled them"untruncated". A filter on intention-to-treat andweights == "untruncated"now returns no rows. Filter onestimand == "itt"alone. -
The step before the per-protocol analysis set reads “Deviated from assigned strategy at time zero”, with a count for each arm. The CONSORT diagram shows it as a censoring box, and the flow data and the attrition sheet get
change_interventionandchange_comparator. The step read “censored (administrative end of follow-up)”, but these person-trials deviate at time zero and stay in the intention-to-treat analysis. - The deviation count is the intention-to-treat analysis set minus the per-protocol set, in each arm. A trial that enters at or after the administrative end gets its own step, “Entered at or after the administrative end of follow-up”. The step shows only when its count is not zero. When the intention-to-treat count is missing, the one step names both groups.
- TARGET item 8 prints both steps with their arm counts. It reads the flow that the CONSORT diagram reads.
New features
-
$get_estimates()returnsn_trials_intandn_trials_cmp, the person-trials of each arm in the analysis file of each row. It has 47 columns. -
$get_protocol_table(ett_id)returns the target trial protocol table of one ETT, with 7 rows and 3 columns. It is the table of the “Target trial protocol” sheet, so a manuscript can build its protocol table from the spec. -
The emulation column of the protocol table names each variable, outcome, exposure level and criterion by its
nameor arm label in the spec. A variable without aname, such as the treatment variable, keeps its column name. The Outcome cell is one line, and it no longer repeats the outcome column.
Documentation
-
The
$get_baselines(),$get_matching()and$get_estimates()help pages,vignette("tte-methods")andvignette("tte-timing")state that the per-protocol analysis set omits the person-trials that deviate at time zero. The intention-to-treat analysis keeps them, and the time-zero censoring factor is fitted on them.
swereg 27.2.3
New features
-
tteplan_excel_spec_summary()writes the Excel specification from the spec YAML and a code registry, without a plan or a skeleton. For the same spec, code registry and arguments, each cell matches$excel_spec_summary()at s0. The one exception isAdmin censoring:, which readsnot built (spec only)withoutglobal_max_isoyearweek.
swereg 27.2.1
New features
-
A spec MAY declare a
sparse_ruleblock, which turns a rare baseline condition into an exclusion criterion of the whole study (#52). The new s1 step s1b counts, per arm in eachdecided_byenrollment, the persons who hold each candidate condition at baseline. A condition whose smallest count is belowmin_nis excluded in every enrollment, and leaves every confounder set. s1 drops a person-trial when the condition holds at that trial’s own recruiting week, and the attrition table gets one step per excluded condition. The rule is decided from baseline counts in the eligible population, before the comparator draw and without follow-up outcomes.plan$get_sparse_decision()returns the frozen decision. s1c, s2 and s3 stop when the rule in the spec no longer matches it. When s1b fails, the plan keeps its previous decision, rule hash and confounder sets. -
Every output that names the population or the adjustment set reads the frozen
sparse_ruledecision (#52).$export_tables()adds a “Sparse conditions” sheet: each candidate, its smallest arm count in the driving enrollments, the enrollment and arm that hold it, and the decision. A count belowmin_nshows as<min_n. Study Specification states the rule, the excluded conditions and the final adjustment set, and marks each excluded confounder. Table 1 drops each excluded condition and names it in a footnote. The Enrollments and ETTs sheets say whether each enrollment drove the decision or inherited it. The CONSORT diagram and the attrition sheets label each step-2 row with its condition and the rule. In the TARGET checklist, item 6a states the rule and the exclusions, item 6g the final adjustment set, and item 7 the methods text. In the target trial protocol table, the rule’s cells depend on the stage. Before s1, the eligibility emulation cell says only that the rule is not decided yet. After s1, the specification column lists the excluded conditions as eligibility criteria. The eligibility emulation cell states the rule and how it chose them, and the Analysis plan cell gives the final adjustment set. Study Specification and TARGET items 6a and 7 state the basis of the rule before and after s1. The Sparse conditions sheet and the protocol table state it after s1. Each says the rule is decided from baseline counts in the eligible population, before the comparator draw and without follow-up outcomes. Study Specification and item 7 say that other comparisons inherit the decision only when at least one enrollment does not drive the rule. -
s2, s3,
$export(),$export_tables()and$print_target_checklist()refuse the files and results of an earlier s1 run (#52). s1 drops the stored s3 results, and s3 stops until s2 rewrites each analysis file after the current s1. -
$excel_spec_summary()writes the Target trial protocol sheet, and the Sparse conditions sheet once s1 has decided the rule. Both come from the writers of$export_tables()and read no s2 or s3 result, so the workbook is a preregistration export from s0 on. The new argumentprotocol_ett_idnames the ETT of the protocol sheet. -
$print_spec_summary()states the rare-condition rule:min_n, the driving enrollments, the candidates, and the decision. Before s1 the decision readsnot decided yet (s1 has not run), in the Study Specification sheet and TARGET item 7 too.
Breaking changes
-
swereg refuses a
TTEPlanor aTTEEnrollmentsaved before 27.2.1. The plan schema is 6 and the enrollment schema is 7. Rebuild the plan with s0 and re-run s1, then s2 and s3. -
tteplan_check_spec(),tteplan_read_spec()and s0 refuse an exclusion criterion with the windowlifetime_before_and_after_baselineon an outcome variable. That window reads the weeks after time zero, so the criterion selects on the follow-up outcome.tteplan_check_spec()reports it as kindoutcome_selecting_exclusion.
Bug fixes
- The forest axis labels ticks below 0.1 when a plotted bound needs them (#56). The ticks are 1 and 3 times each power of 10, down to the first tick at or below the smallest bound. Before, a ratio of 0.004 had no labelled tick below 0.1.
-
A forest label format drops a
({field}...)group when that field is empty (#56). Before, it dropped only a group that held the placeholder alone, such as({follow_up}), so({follow_up}w)printed(w). -
add_annual()does not add or change any column of the caller’sdata(#56). Before, it wroteisoyearintodata. -
tteenrollment_irr_combine()pads every value of a column to one width (#56). Before,0.0040stood beside1.23. - The MDE columns accept two ratios that round to 1 (#56). With very large event counts, one or both ratios can round to exactly 1. The orientation check allows a 1e-12 tolerance, so such counts no longer stop the export.
-
s2 drops the stored s3 results, and
$export(),$export_tables()and$print_target_checklist()refuse a result from before the last s2 run. s2 stamps each ETT with a new run id inett$s2_run_id, s3 copies it into each result, andtteplan_load()keeps both. A result computed under other IPCW options of the same s1 run is refused. -
$export(),$export_tables()and$print_target_checklist()stop when thesparse_ruleinplan$specchanged after s1.$excel_spec_summary()and$print_spec_summary()show a stored decision only when it belongs to the current rule and to a completed s1. Otherwise the rule readsnot decided yet (s1 has not completed for this rule). -
$recompute_baselines()refuses an ETT whose analysis files do not come from the current s1 run, as$s3_analyze()does. A recomputed panel takes the s1 and s2 run stamps of the file it reads. - The supplementary baseline sheets, Table S2 onward, drop the conditions the rare-condition rule excluded and carry the Table 1 footnote.
-
Study Specification, the protocol table’s Analysis plan cell and TARGET items 6g and 7 say which models the adjustment set enters. The treatment model and the time-zero censoring model hold the adjustment set alone, with no time or calendar term. The loss and deviation censoring models add the time since time zero and the calendar period. The outcome model holds no confounder. Each confounder line of the protocol table reads
Treatment and censoring models adjust for. It comes before the outcome model line, which names its own terms.
Documentation
- A new article,
vignette("tte-preregistration"), “TTE preregistration: the OSF form”, maps each section of the OSF Preregistration form to the specification YAML and the Excel specification. -
The vignettes describe current behaviour only. The release history is in
NEWS.md. -
The
add_onetime()help page states that an existing skeleton column is overwritten for each person found indata. It said that the incoming column got ani.prefix. -
Two vignette claims are corrected. A
^in a code pattern is literal, so the pattern matches nothing. The stored-object schema thresholds are 7 for an enrollment and 6 for a plan. - The
studyargument oftteplan_from_spec_and_registrystudy()documents the list that MAY stand in for aRegistryStudy, and the fields that list needs. vignette("tte-workflow")callstteplan_from_spec_and_registrystudy()with its real arguments, andvignette("tte-preregistration")lists the sheets of the Excel specification.-
The
add_diagnoses()andadd_cods()help pages state that a bracket group such asI2[0-5]expands to its literal prefixes. They said that a bracket is taken literally. -
The help pages describe current behaviour in the present tense, and name a schema number instead of a release. The
default_n_workers()page states that s2 takesn_workersabove 1.
Internal
- The s1 sub-steps s1b, s1c and s1d are renamed s1c, s1d and s1e (#52). The name s1b is free for the step that #52 adds before the comparator draw. The log labels, the batchit item ids and the work-directory file names use the new names. s1 clears a leftover work directory with the old names at its start.
-
The s3 stamp on each stored result is renamed
swereg_version_s3(#56). Under the old nameswereg_version,$swereg_versionreadswereg_version_s2when the s3 stamp was absent. swereg refuses plans saved before 27.2.1, so no reader falls back to the old name. - The IPCW fit label names the error that starts the refit (#56). It reads “bam, discrete = FALSE, after the discrete prediction failed with an object-not-found error”.
-
The internal writers
.write_combined_rates(),.write_combined_rates_irr(),.write_combined_irr()and.write_itt_vs_pp_forest()are removed (#56). No export called them. Six helpers that only they called go with them:.prepare_combine_data(),.build_itt_vs_pp_df(),.render_itt_vs_pp_overlay(),.rename_treatment_columns(),.ett_descriptions()and.ff_ci_only(). -
ignore_unused_imports()is removed (#56).tools:::.check_packages_used()findsmgcvandsurveythrough the top-level functions that call them. - The validation evidence hash covers the parsed code of the estimator files, not their bytes. An edit to comments, roxygen, whitespace or blank lines no longer makes the evidence stale.
-
The unused internal helpers
.t1_wtd_props(),.t1_wtd_missing()andvalidate_date_column()are removed. None was exported, and no code called them.
swereg 27.2.0
New features
-
Every s3 result records the swereg versions that ran s2 and s3 (#45).
$export_tables()and$export()warn when either differs from the exporting version. The Provenance sheet shows them in the rows “swereg version (s2)” and “swereg version (s3)”, below “swereg version (export)”. An ETT result records every distinct s2 version of the analysis files it reads, PP and ITT. When some stamps are missing and others are present, the sheet listsunknownafter the known versions, and the export warns. When no result carries a stamp, both rows readunknown. Results from an analysis file of another s2 version use the estimator methods of that version. s3 warns about them, and about an analysis file without a recorded version. -
The PP results and ITT results sheets report the minimum detectable effect (MDE) (#54). Three columns follow the IRR p-value: the expected intervention events under the null, and the protective and harmful MDE ratio. The MDE uses the
powerargument of$export_tables(), default 0.8, and the study confidence level.powerMUST be at least 0.5 and below 1. A power below(1 - conf_level) / 2would swap the protective and harmful ratios. The naive Poisson formula uses unweighted counts and ignores repeated contributions of one person. So it can differ from the detectable effect of the weighted estimator with person-clustered standard errors. A plan analysed before 27.2.0 shows blank cells. -
Each row of
$irr_by_subgroup()names the formula of its own fit in amodel_formulacolumn (#55). The whole-cohort fit and each stratum fit choose their time terms from their own rows, so the formulas can differ. A row that no fit produced readsNA.
Bug fixes
-
A positive incidence rate ratio below 0.01 prints (#31). Before, a 0.01 estimability floor blanked it. The results sheets, the forest labels and
tteenrollment_irr_combine()print any ratio or bound below 0.01 with two significant digits, as0.0040. Values of 0.01 and above keep two decimals. A plan analysed before 27.2.0 keeps its stored decision until it is rerun. -
tteenrollment_rates_combine()accepts a rates table from before 27.2.0 beside a newer one. Before, the extra unweighted columns made it stop. -
skeleton_eligible_no_observation_in_window_excluding_wk0()counts a week wherevarisNAas no observation (#55). Before, theNAmade a later week’s eligibilityNA. -
The IRR interval headers of the PP results, ITT results, Weight truncation (PP) and Effect modification sheets state the study level (#55). That level is
study$implementation$conf_level, or 0.95 when the specification names none. Before, they printed95% CIat every level. The internal writers of the ITT vs PP sheet and its figure do the same, but no export calls them. tteenrollment_irr_combine()andtteenrollment_combined_combine()takeconf_level, default 0.95, and head the interval column with it (#55).- The vignettes no longer describe pipeline snapshots (#55). swereg removed the feature in 26.7.18.
-
Item 6h of the TARGET checklist states the censoring-model settings that s2 used (#55). s2 records
estimate_ipcw_pp_with_gamandestimate_ipcw_pp_separately_by_treatmenton each per-protocol analysis file, and s3 stores them inresults_ett. Before, item 6h described the defaults. s2 records nothing when it runs before 27.2.0, or when it builds the analysis file from an s1 file of before 27.2.0. For a result without the record, item 6h states that the settingwas not recorded (s1 or s2 ran before swereg 27.2.0). -
A censoring model whose
mgcv::bam(discrete = TRUE)prediction stops with an “object not found” error is fitted again withdiscrete = FALSE(#55). mgcv 1.9.4 gives that error whens()sits besidefactor()orsplines::ns(), and s2 then stopped. Other prediction errors still stop s2. -
The knot warning of
splines::ns()no longer setswarnon an IRR fit (#55). The warning is spurious: on a short panel with 4 values oftstart, the ratio and its standard error equal those of thefactor(tstart)fit.
Internal
- The full validation tier and the evidence generator read the replicate count from
val_n_replicates(): 60 in s3, 20 elsewhere (#55). - The CI
max-cyclocompceiling is 86, the measured maximum inR/(#55). -
A test pins that s1 clears a work directory from before 27.1.1 before its first sub-step writes (#55). Such a directory holds plain data.tables, and
qs2_read()refuses only an oldTTEEnrollmentorTTEPlan. -
The validation evidence holds the ITT design limits of s3 and s4 in
$itt_design_limit(#55).vignettes/validation-claims.Rreads them there and stops when they are absent. Before, it hard-coded them. -
The evidence keeps its s3 ITT coverage row (#55). The #55 item said that the vignette no longer claims it. That was wrong: Table 15 and the claim
coverage_itt_s3_designofvignette("tte-methods")read the row. -
The fast validation tier gains an s1 per-protocol cell built by
enroll()from weekly rows (#55). It checks the weekly deviation boundary of each person-trial against the simulated treatment. It also checks that its estimates equal those of the panel that the other cells build, to 1e-6. Before, every fast-tier panel used the fallback read ofs5_prepare_outcome(). - Section 3 of
vignette("tte-methods")has no sentence over 25 words, and section 1.8.9 cites the rank example once (#55).
swereg 27.1.3
Bug fixes
-
$export()stops when a CONSORT or forest figure writes no PNG (#35). It deletes the old PNG first, so a file from an earlier run cannot stand in for the new one. This protects a direct$export()user. -
$reload_spec()setsoutcome_roleandoutcome_descriptiontoNAwhen the new specification omits them (#33). Before, the old value stayed onplan$ett. -
A survival exhibit without
age_groupstops with a message that namesage_group(#26).age_group = NAselects the ETT with no age group. -
$survival_curve()stops on anNAtreatment or on one arm (#28). Before, anNAtreatment drew a third curve. -
A forest row label drops
({outcome_role})when the outcome has no role (#29). Before, it showed “Stroke ()”. -
A forest figure with
group_by = "outcome"labels each row with its exposure name from the manifest (#30). It orders the outcomes as the specification lists them. -
$export()stops on an emptyestimandsand on a manifest element that is not a list (#32). The message names the position of the exhibit in the manifest. -
add_annual()givesNAto a person with more than one row indata, and warns once with the count of such persons. Before, the person silently took the values of the last row.
Internal
-
testthat runs the test files in parallel processes (#53).
TESTTHAT_CPUSsets the number of processes, andTESTTHAT_PARALLEL=FALSEruns the files in one process. The six simulation and validation files start first.
swereg 27.1.2
New features
-
tteplan_check_spec()lists every problem of a specification as a row, and never stops.?tteplan_check_specnames the six problem kinds.
Internal
-
The always-on evidence test compares
$meta$estimator_hashwith the estimator source, and it runs underR CMD check. Before, it compared$meta$sweregwith DESCRIPTION, so a version bump with no estimator change failed it. The full tier no longer repeats the hash test. -
swereg removes the four tests that read the study specifications. Their result depended on the machine, and they skipped in CI. The owner of the specifications runs
tteplan_check_spec()instead.
swereg 27.1.1
Version numbers
-
The version scheme returns to
YY.M.D. This release bumps the version ahead of the calendar to restore the scheme, so every release until 2027-01-01 is 27.1.x.
Breaking changes
Per-protocol estimates change. The deviation week leaves per-protocol follow-up, and the censoring weight changes to three models fitted on new risk sets. The entries below and under “Bug fixes” give each change.
The per-protocol censoring weight is the product of three models in each arm: loss to follow-up, protocol deviation, and protocol deviation at time zero. Before, one model covered both causes. The time-zero model is a logistic model on the baseline confounders, fitted on every person-trial, including those with no follow-up. The columns
ipcw_pp_loss,ipcw_pp_deviationandipcw_pp_time_zerohold the three factors.-
The outcome models now adjust for the trial and the time since time zero, and the censoring models change with them. Before, the outcome models read
trial_id, which on a follow-up row is the calendar period of that row and not the trial. Danaei 2013, Caniglia 2023 and the TrialEmulation package adjust the outcome model for the trial and the follow-up time. Every IRR, subgroup IRR, effect-modification test, heterogeneity test and censoring weight can change. The offsetoffset(log(person_weeks)), the family, the engine and the stratification by arm are unchanged. Section 1.8.9 ofvignette("tte-methods")gives the reason for each term.Model Time terms in 26.15.0 Time terms in 27.1.1 IRR and subgroup IRRs tx + ns(tstop, df = 3) + ns(trial_id, df = 3), with a lineartrial_idbelow 5 valuestx + term(tstart) + term(enrollment_period_id)Effect-modification test tx * factor(sg) + ns(tstop, df = 3) + ns(trial_id, df = 3), with a lineartrial_idbelow 5 valuestx * factor(sg) + term(tstart) + term(enrollment_period_id)Heterogeneity test tx * ns(trial_id, df = min(3, n - 1)) + ns(tstop, df = 3)tx * ns(enrollment_period_id, df = min(3, n - 1)) + term(tstart)IPCW denominator term(tstart) + s(trial_id) + confounders, with a lineartrial_idoutsidemgcv::bam()or below 10 valuesterm(tstart) + term(period_id) + confoundersIPCW numerator term(tstart) + s(trial_id), with a lineartrial_idoutsidemgcv::bam()or below 10 valuesterm(tstart) -
One helper,
.tte_time_term(), builds every time term. The table shows it asterm(x). Forndistinct values ofxin the rows of the fit, it returns:-
s(x)whennis 10 or more and the fit is the IPCWmgcv::bam()fit; -
splines::ns(x, df = 3)whennis 4 or more ands(x)does not apply; -
factor(x)whennis 2 or 3; - no term when
nis 1.
For IPCW,
ncounts the rows of each fitted stratum after the rows of zero width are removed. -
The column
trial_idis renamed by what it holds. The calendar period of a skeleton week or a panel row isperiod_id. The trial of a person-trial isenrollment_period_id.The
trial_idcolumn ofget_attrition()andget_matching()is nowenrollment_period_id. The global attrition row holdsNAin that column.TTEEnrollment$new()stops whenenrolled_idshas atrial_idcolumn. The error namesenrollment_period_id.qs2_read()refuses a storedTTEEnrollmentbelow schema 6 and a storedTTEPlanbelow schema 5. A saved R6 object runs the method bodies it was saved with, so a migrated object can fit the earlier time terms with no warning. The column migrations of 26.15.0 are removed.Every project MUST run s0, s1, s2 and s3 again. Rebuild the plan with s0, then run s1, s2 and s3. An s1 work directory from an earlier release MUST NOT be resumed, because its files hold the retired column.
tteplan_read_spec()stops when an enrollment has notreatment$implementation$seed. The error names the enrollment.
New features
-
$irr(),$irr_by_subgroup()and$effect_modification_test()takeconf_level, with the default 0.95. s3 passesstudy$implementation$conf_levelto each. Before, only the risk-difference intervals followed it. -
Each fit records the formula that it fitted. The results of
$irr(),$effect_modification_test()and$heterogeneity_test()hold it inattr(x, "model_formula"). The field$ipcw_formulas[[stratum]][[cause]]of an enrollment holds the formulas of each censoring model. A cause with no model holdsfitted = FALSEand the reason.
Bug fixes
- Per-protocol follow-up stops at the start of the first discordant week beyond the arm’s tolerance. Since 26.9.0 the deviation week counted as adherent follow-up, and an event later in the same follow-up interval counted too. TrialEmulation, Danaei 2013 and Hernán 2008 exclude the deviation interval.
- The per-protocol censoring models no longer count event rows as uncensored. The loss model is fitted on the rows without an event, and the deviation model on the rows without an event that were not lost.
- The global attrition rows no longer count rows with no trial, such as annual rows, as person-trials. The global person-trials now equal the sum of the per-trial person-trials at every step. The global “Before exclusions” and “Outside of study years” person-trial counts fall, and a global person count can fall.
- A
no_prior_valuewashout no longer counts a missing week in its window as the value. - The CONSORT analysis step reads “censored (administrative end of follow-up)”. It read “censored (per-protocol)”.
- TARGET item 6c states that the draw ran from a stated seed, because every specification now states one. Its sentences for a plan with no seed, or a seed in some enrollments only, are removed.
- TARGET item 6h states the time terms of the censoring models and the outcome model in this release.
- TARGET item 7a lists the inclusion criteria of the specification, with the helper that item 6a uses.
Validation
- The validation tests compare each estimate with an exact truth. The risk and the risk difference by horizon come from an exact recursion. The log-IRR truth is swereg’s outcome model fitted to the exact expected events and person-time, with each cell weighted by its probability of remaining uncensored.
-
A fast validation tier runs inside
R CMD checkon every push.test-validation-fast.Rchecks the risk difference at horizons 5, 10 and 20 and the log-IRR: scenario s1 for both estimands, and scenario s4 per-protocol. It fails when the deviation interval counts as per-protocol follow-up. -
The full validation tier runs when
SWEREG_RUN_VALIDATION=true. It covers scenarios s1 to s4, both estimands, truncated and untruncated weights, and the bootstrap coverage of the risk difference. The new workflow.github/workflows/validation.ymlruns it weekly, onv*tags and on demand. The coverage and stress tests use the same variable, in place ofSWEREG_RUN_COVERAGEandSWEREG_RUN_STRESS. -
Two tests fail when the validation evidence is stale. One fails when
$meta$sweregdiffers from the DESCRIPTION version, and it always runs. The other runs in the full tier and fails when$meta$estimator_hashdiffers from the md5 of the six estimator source files. -
Each validation claim of
vignette("tte-methods")is a named predicate invignettes/validation-claims.R. The vignette stops building when a claim is false, andtest-vignette-claims.Rchecks every predicate against the shipped evidence.
Documentation
-
vignette("tte-methods")has a new subsection, 1.8.9 “Time terms”. It names the three time axes and the two axes that each model uses, gives the reason for each choice, and has three figures. vignette("tte-nomenclature"),vignette("tte-timing")andvignette("tte-methodology")useperiod_idandenrollment_period_id.-
The validation evidence in
vignette("tte-methods")is regenerated with swereg 27.1.1. It came from swereg 26.7.4. Section 3 adds the risk difference at horizons 5, 10 and 20 against the exact truths, with its bootstrap coverage.
swereg 26.15.0
Breaking changes
-
The panel column
entry_band_idis nowenrollment_period_id. It is a column of theTTEEnrollmentpanel,$data. -
The
bandcolumn of$get_curves()and of the stored risk-difference rows is nowfollow_up_interval. The stored rows are therd_*slots ofresults_ettin aTTEPlan. Therd_curve_*slots keep theirtstopcolumn. -
Downstream code that reads
entry_band_id, or thebandcolumn of$get_curves()or of the stored risk-difference rows, MUST change to the new name.qs2_read()renames the columns in a saved file, as “New features” states. - Both estimands censor at the first missing week after time zero. A gap in observation is loss of observation, and an outcome at or after the gap is never counted. Intention-to-treat used to continue through a gap. After a gap of a whole follow-up interval, it also moved the later rows one interval early on the follow-up axis. Per-protocol used to count an outcome after a gap in the same follow-up interval. Under per-protocol, a treatment deviation keeps its event priority.
-
The CONSORT and attrition labels changed, as the next five entries state. The CONSORT diagrams, the
step_labelcolumn of the$export_tables()attrition sheets and TARGET item 8 show these labels. - “Censored before landmark” is now “Not under observation at time zero”. The old label was false, because a death is not censoring.
- “Event before landmark” is now “Event before time zero”.
- “Has invalid treatment” is now “No week in either treatment arm”.
-
A washout exclusion label now names what the step drops,
"Prior <variable> ...", in place of the rule"No prior <variable> ...". The protocol table and TARGET item 6a keep the rule. -
Three steps that printed their raw step name now have labels.
landmark_candidatesis “Candidate person-trials, before the time-zero checks”.enrolled_after_comparator_drawis “Enrolled after the comparator draw”.analysis_datasetis “Analysis dataset (per-protocol)”. -
The comparator-draw box of the CONSORT diagram says “stratified by the 4-week enrollment period”, not “entry band”. The number is the design’s
period_width. -
The
landmark_*step names are unchanged. They arelandmark_candidates,landmark_observedandlandmark_event_free.
New features
-
qs2_read()loads a plan or an enrollment that swereg 26.14.0 saved, with the columns above renamed. It acts on aTTEPlanat schema 3 and aTTEEnrollmentat schema 4, before the schema check. Every number is unchanged. -
An enrollment with an
observed_var, enrolled before swereg 26.15.0, warns once per file per R process that gaps cannot be detected in it. Its panel has noweeks_to_observation_gapcolumn, so an outcome after a gap may count. Re-run$s1_generate_enrollments_and_ipw()to remove the limitation.
Bug fixes
- TARGET items 6d and 7d state the time-zero definition. Item 7d said follow-up began at the start of the enrollment period. Both items now say that time zero is the first week after the enrollment period closes.
- TARGET item 6h states its censoring model as the default settings of the pipeline. The plan does not record the arguments that s2 ran with, so the old sentence stated the model as a fact that nothing checked.
- TARGET items 6f, 6h and 7 state the per-protocol censoring rule. They said that per-protocol follow-up stopped at treatment switching. Follow-up stops when the run of weeks off the assigned strategy exceeds the arm’s tolerance, and a missing treatment status counts as off the strategy.
- TARGET item 6c states a seed only when the specification states one. It printed “The draw ran from a stated seed.” for every plan.
Documentation
-
vignette("tte-methods")follows the step-3 protocol. Section 1 opens with the values the protocol must state, follows TARGET items 6a to 6h in order, and holds no code. - The help pages, the vignettes and the generated text use one vocabulary: enrollment period, follow-up interval and time zero. “Landmark” is now a defined term: time zero is a landmark.
- The cross-references between the vignettes point to the renumbered sections of
vignette("tte-methods").
swereg 26.14.0
Breaking changes
-
tteplan_read_spec()refuses a secondage_rangein one enrollment. Everyage_rangewrites the one columneligible_age, so the second entry replaced the first and the first never applied. A 30-year-old was eligible under an enrollment that also declared age 45 to 59. Merge the entries into one.
Bug fixes
-
TARGET item 6a lists the criteria each enrollment adds. It listed only the global ISO years and the global criteria, so an enrollment’s age range, ISO-year range, inclusions and exclusions were missing from the methods text. Enrollments that add the same criteria in the same order share one block. A
has_eventinclusion now states its rule, at least one event in the window. A spec without globalisoyearsprinted no criteria at all, and now prints them. -
The protocol table’s emulation cell states an enrollment-level exclusion as “is TRUE”. It printed “Drop rows where
is (not specified)”. The pipeline drops the rows where the column is TRUE, as it does for a global exclusion. -
An ETT description names its enrollment, as in
"ETT00001: Outcome A (PE01, 52w, age 45-59)". Two enrollments that differ only in anisoyear_rangegave descriptions that differed only in the ETT id. A cached plan gets the new description from the cosmetic spec reload. -
tteplan_validate_spec()checks that the skeleton hasisoyearwhen an enrollment declares anisoyear_range. A skeleton without it failed later, insideskeleton_eligible_isoyears().
swereg 26.13.1
Changes
-
$export_tables()draws a Love plot for every enrollment. Each one gets aLove_<enrollment>sheet after its attrition sheet, and the sidecarstables_love_plot_<enrollment>.pngand.pdf, the way the CONSORT diagrams already did. The singleLove plotsheet andtables_love_plot.pngare gone. The Love plot no longer depends ontable1_enrollment, so a wrong id there can no longer cost it. An enrollment with no stored numeric SMDs gets no sheet, and theLove plot sidecarsrow of the table of contents names it. The export deletes the old singletables_love_plot.pngand.pdf, and the sidecars of any enrollment it skips, so the results directory holds only the Love plots the workbook names. A Love plot that fails to render now stops the export instead of leaving a note on its sheet.
swereg 26.13.0
New features
-
An enrollment’s
additional_inclusiontakesisoyear_range, which limits trial registration to the ISO yearsmintomax. A secondage_rangeused as a year limit replaced the first one, so the enrollment lost its age limit with no error. Follow-up continues pastmax. Seevignette("tte-spec-schema").
swereg 26.12.2
New features
-
tteplan_locate_and_load()takescandidate_dir_specandcandidate_dir_results. A plan stores the spec and results candidates of the checkout that built it. A later stage run from another checkout read that checkout’s spec and wrote its results there. Each argument, when given, replaces the stored candidates.
swereg 26.12.0
Breaking changes
create_skeleton()takesisoyear_minandisoyearweek_maxin place ofdate_minanddate_max. The old signature built an annual row and a weekly row of the same ISO year wheneverdate_minwas not the Monday of week 01.date_min = "2000-01-01"gave"1999-**"and"1999-52", so week 1999-52 counted twice inpersonyears. The annual rows now run from 1900 toisoyear_min - 1, and the weekly rows from"<isoyear_min>-01"toisoyearweek_max. To convert a call, pass the ISO year whose week 01 starts the weekly rows asisoyear_min, andcstime::date_to_isoyearweek_c(date_max)asisoyearweek_max. For example,("2000-01-01", "2023-12-31")becomes(2000, "2023-52"). The old arguments stop with these directions.any_events_prior_to()counts calendar weeks, and itsisoyearweekargument is required. The window of a row was the N rows before it. An event in a person’s first N rows was therefore invisible to the rows after it, and an annual row ("2004-**") counted as one week. The window is now the N ISO weeks before the first week of the row. An annual row covers every ISO week of its year, and an earlier row is inside the window when any of its weeks is. An annual row and a weekly row of the same ISO year overlap, so the function refuses them as out of order. Missing values followany(): an event in the window gives TRUE, and an NA gives NA only when no event is there. The function stops whenisoyearweekis missing, has an unknown week, or is out of calendar order.skeleton_eligible_no_events_in_window_excluding_wk0(),skeleton_eligible_no_observation_in_window_excluding_wk0()and the TTE eligibility step read the table’sisoyearweekcolumn the same way. A window of 99999 or more,Infincluded, means lifetime.
swereg 26.11.2
Documentation
-
TARGET checklist item 6h no longer makes three false methods claims. It said both the outcome model and the censoring model used “sequential trial indicators”. The outcome model uses a natural cubic spline of the trial index (linear with 2 to 4 trials, none with one). The censoring model, with its default GAM, uses a penalised spline (linear with 2 to 9 trials, none with one). It said the censoring model was “conditional on baseline covariates”; it reads the most recently updated confounder values. It said the IPCW numerator was a “marginal (population-average) censoring probability”; it is a second fitted model with the same time terms and no confounders.
vignette("tte-methodology")repeated the numerator claim in three places and the IPCW trial-term threshold in one, and those are corrected too.
swereg 26.11.1
Documentation
The TTE methods text now covers the risk difference and the number needed to treat.
$s3_analyze()has computed both for every ETT, butvignette("tte-methods")described only the incidence rate ratio, so a methods section written from it left them out. Section 1.2 defines the absolute estimand and states that the risk is cause-specific: death and the end of observation censor follow-up, and there is no competing-risk model. Section 1.7 gives the weighted discrete-time survival estimator. Section 1.8 gives the person-level percentile bootstrap, the missing interval for an arm with no event, and the rule for the interval of the number needed to treat. Section 1.11 lists the two limitations, and Section 2 carries the same in manuscript prose.vignette("tte-methodology")has a new row in its outcome-model table (#50).The TARGET checklist names the absolute scale. Item 6h now describes the risk difference, its bootstrap interval at the study’s confidence level, and the number needed to treat. Item 13 names
$risk_difference()beside$irr().Plainer wording in
vignette("tte-methods"). The introduction and Sections 1.2, 1.6, 1.8, 2 and 4 are rewritten in shorter sentences, without em dashes.
swereg 26.11.0
Breaking changes
$pipeline_hash()is replaced by$pipeline_identity(), on bothRegistryStudyandSkeleton. It returns the five components that decide what a freshly built skeleton looks like, rather than one digest over them, so a mismatch can name the component that moved.$skeleton_pipeline_hashes()returnsidentity_hashin place ofpipeline_hash, plus anidentitylist column, and takes an optionalfilesargument.$assert_skeletons_consistent()returns the identity hash and takes the samefilesargument.The
RegistryStudyschema is version 7, so an existingregistrystudy.qs2is refused. An R6 object serializes its METHODS as well as its data, andregistrystudy_load()is a bare read, so a study saved by an earlier swereg would have run that swereg’s consistency check: the order-sensitive one this release replaces. Re-run the generator to write a current file.RegistryStudy$write_skeleton_meta()is removed. Nothing called it, here or in any consuming project, and its own documentation recorded that a meta it wrote over an existing one dropped theframework_removalsfield.$save_skeleton()is the route and is unchanged.manifest_versionis 2. The manifest carriesidentity_hashandpipeline_identityin place ofpipeline_hash.The next
$process_skeletons()is a full rebuild.labelno longer enters a code entry’s fingerprint, so every fingerprint moves once. Measured on the MHT study: 0 of its 8 code entries keep their fingerprint across this release.
Bug fixes
Phase 2 replays the entries that read a changed entry, not only the entry that changed.
$sync_with_registry()invalidated by set difference: it droppedstored - currentand appliedcurrent - stored. That is correct only if code entries are independent, and they are not..apply_code_entry_impl()hands a registered function the whole skeleton, so entry B may read a column entry A wrote. Editing A re-applied A alone and left B holding a value derived from the OLD A, with nothing recording that B was stale. A pure reordering of the registry did no work at all, because the fingerprint set was unchanged. Phase 2 now uses divergence-point rewind and replay, the same semantics$sync_randvars()uses for phase 3: everything at or after the first differing fingerprint is dropped and re-applied in registration order. Registration order is therefore correct by construction, which is also what the ordering defect below needed.A re-applied code entry no longer reports a current skeleton as obsolete.
$sync_with_registry()re-applies a changed entry at the END, so editing any entry but the last leftSkeleton$applied_registryin application order while the study stayed in registration order. The same eight fingerprints then hashed two ways and the commit gate rejected a complete 2194-batch rebuild. The replay above leaves the stored order equal to registration order, so the comparison stays strict and ordered. Application order is semantic:.apply_code_entry_impl()hands a registered function the whole skeleton, so one entry may read a column another wrote, and sorting the fingerprints would have declared that meaningless.The replay reaches skeletons written by earlier versions.
.sync_code_registry()is a free function called from.process_one_batch(), andSkeleton$sync_with_registry()delegates to it. A skeleton read back from disk carries the method bodies of the swereg that wrote it, so a fix living in the method would have applied only to skeletons that did not need it.The per-batch fast path stops missing on every batch.
.meta_matches_pipeline()compares the stored fingerprints in order. Against a store left in application order it missed for every batch of every later run: load the whole skeleton, find nothing to do, save it again. Normalizing the stored order makes that comparison correct rather than relaxing it.$skeleton_pipeline_hashes()no longer calls a method on a deserialized skeleton. The fallback branch reads the stored fields through.stored_pipeline_identity(), which also serves the sidecar branch. One construction replaces three that a comment required to stay aligned.Editing a code entry’s
labelreplays nothing. It is documented as the human-readable name$describe_codes()prints and writes no column.$compute_summary()now takes the label from the current registry, looked up by fingerprint, so dropping it from the fingerprint cannot leave a stale label in the summary.
New features
$s1_generate_enrollments_and_ipw()asserts the skeleton store before reading it.$assert_skeletons_consistent()documented itself as the pre-flight fortteplan_from_spec_and_registrystudy()and nothing called it, so the gate could reject the producer after a full rebuild yet never stop the consumer. It now runs at plan construction as an early warning, and again at s1 on the files s1 is about to read, because a plan is saved, reloaded elsewhere and re-resolvesskeleton_files. Per-batch replay never looks across batches, so$process_skeletons(batches = 1:100)after a code edit leaves a mixed store with every filename present.check_skeletonscontrols both gates. It is validated as a scalar non-NAlogical, and astudythat cannot be checked is an error rather than a warning: a checking gate that cannot check must not report success.
swereg 26.10.25
New features
-
tte_stage(..., "s3")prints the TARGET checklist. The analysis set does not exist until s3 stores the baseline panel. s1 was the only stage that printed the checklist before, so its Item 8 had no analysis line.
Bug fixes
TARGET checklist Item 8 no longer prints a negative exclusion count. It differenced two cumulative eligibility levels, and
.s1_compute_attrition()re-derives the arm at each level. A criterion can therefore move a person-trial between arms instead of out of the cohort. A step line now carries one total. Arm counts appear at the eligible cohort, the comparator draw and the analysis dataset.R/consort.Rprints the same three, so the methods text and the CONSORT figure now agree.The CONSORT “Excluded” box labels the two landmark exclusions. They read “Censored before landmark” and “Event before landmark”, in place of
landmark_observedandlandmark_event_free. The box no longer listslandmark_candidates. Its mask keeps every row, so it excludes nobody..tte_prevalent_positions()asserts that each person’s weeks run forward in time. It selects rows withduplicated(), so it depends on the order of each person’s own subsequence. A skeleton sorted on anything else returned the wrong rows, with no warning.
Documentation
vignettes/tte-spec-schema.Rmdnames the inclusion types each container accepts. It said thatinclusion_criteria$criteriatakeshas_eventalone. That container also takesno_prior_valueandonly_prior_value, declared underimplementation:.enrollments[]$additional_inclusiontakes those three andage_range.vignettes/tte-spec-schema.Rmdno longer claims one column name for every criterion. It said thattteplan_apply_exclusions()buildseligible_has_<variable>_<window>for each one. That name holds forhas_eventalone. A washout giveseligible_no_*oreligible_only_*.Five sites in three vignettes called the hot-deck draw the imputation method. They now name
impute_fn, whose default draws one hot-deck value.Three tracked
knitr::purl()artefacts undervignettes/are gone.R CMD buildalready stripped them, so they never shipped. Two were 8 and 13 months out of date with their own.Rmd.
swereg 26.10.24
Bug fixes
TARGET checklist Item 8 counts each exclusion once. It summed the per-trial rows and the global rows together, so every criterion counted twice. It now reads
.attrition_overall(), the rule the CONSORT diagram and the attrition sheet already use. That rule needs a global row for every criterion. An attrition table written before the global rows existed carries one for some criteria only. Item 8 then prints its placeholder, and no enrollment gets a participant flow.$recompute_baselines()skips an enrollment whose panels are current. It recomputed every named enrollment on every call. The staleness rule is.baseline_panel_is_stale(), the one$export_tables()uses. The newforce = TRUEargument recomputes every named enrollment.
swereg 26.10.22
New features
-
$s1_generate_enrollments_and_ipw(work_root = )moves the s1 work directory to{work_root}/s1_work_{project_prefix}. s1 sweeps that root first. It deletes every entry directly under the root older than 14 days.tte_stage()and$slurm_job()forward the argument by name.
swereg 26.10.21
New features
options(swereg.s1_work_root = )moves the s1 work directory to local disk, as{root}/s1_work_{project_prefix}. The delete of its files on an SMB share runs for about 30 minutes. On local disk it takes seconds. The environment variable isSWEREG_S1_WORK_ROOT, and the path MUST be absolute.s1 sweeps that root before every run. It deletes each entry directly under the root that is older than
swereg.scratch_max_age_days(SWEREG_SCRATCH_MAX_AGE_DAYS, default 14 days). A run the scheduler kills never reaches its own cleanup, so the root needs a janitor. Age is the only rule, and the sweep deletes a dotfile like any other entry.
swereg 26.10.20
Bug fixes
tteplan_validate_spec()names the missingidcolumn. The prevalent-user check groups byid, and a skeleton without it died inside the eligibility compiler with data.table’scolumn not found: [id].$check_version()says what schema 4 stores. The refusal now namesfill_summaryandps_fit, so a reader knows which fields the rebuild adds.The CONSORT “Excluded” box groups its bullets under the three CONSORT 2010 headings: “Not meeting inclusion criteria”, “Meeting exclusion criteria” and “Other reasons”. An inclusion label is criterion-voiced (“No prior MHT other than local”), and it read inverted under a bare “Excluded”. The heading carries the direction now, so no label changes.
The TARGET checklist now names
impute_fn. Items 6g and 11 stated hot-deck sampling as a bare fact, which is false for a project that passes its ownimpute_fn. The 26.10.19 Table S1 caption already said it the true way.
Documentation
-
The workflow vignette now calls methods that exist.
$km()is not aTTEEnrollmentmethod, and$rates(by = )and$irr(formula = )are refused. The calls now passweight_colalone, and$km()becomes$survival_curve(). - The class overview moves the
invisible(self)sentence to the stage fence it describes, and says what an estimation method returns.
swereg 26.10.19
New features
$slurm_job("s1")no longer requeues. s1 deletes its own work directory at the start of every run, so a node-failure requeue destroyed the work the failed run had already done.requeuenow defaults toNULL, which givesFALSEfor s1 andTRUEfor s2 and s3.$slurm_job()forwardsenrollment_ids,ett_ids,stabilize,estimate_ipcw_pp_with_gamandestimate_ipcw_pp_separately_by_treatmenttotte_stage(). A partial s3 re-run needed a hand-written job script before.impute_fnstays out: a function has no literal a job script can carry.type: only_prior_valuekeeps a person-week only when every prior week in the window that holds an observation holdsvalue. A woman who takes tibolone and then switches to systemic MHT passesno_prior_value: systemic_mht, and she is not MHT-naive.The prevalent-user check now measures prevalent weeks, and a first initiation is no longer uncovered. It searches all four rule blocks and both washout types, and the warning names each washout with its own count.
$s3_analyze()ends with two lines on stdout. The first names every ETT whose incidence rate ratio isNA. The second names every ETT whose fit raised a warning and still produced an estimate. An ETT with no event in one arm returnsNAand raises a warning, so it appears on the first line only. The fit runs in a worker subprocess whose output the pool keeps only on failure, so a job log carried neither signal before.$irr()carriesevents_interventionandevents_comparatoron every row, and estimability now reads them. A ratio is estimable only when each arm holds at least one event. Counts ofNA, which is what a result stored before this release holds, fall back to the ratio rule.$s2_ipw()counts the person-trials the propensity fit dropped for a missing confounder.stats::glm()drops such a row and says nothing.$ps_fit$n_dropped_na_snapshotholds the count, and the method prints a message when it is above zero.A completed s1 reports the work directory it removed. The success path now uses the helper the pre-run clear uses. It prints the file count and the elapsed seconds, so a delete of thousands of files is no longer silent.
Bug fixes
The TARGET checklist no longer says intention-to-treat analyses were not conducted. Every plan builds both estimands, so the sentence was false for every plan.
.TTE_ESTIMANDSnow declares the set once; the s2 item loop, the s3 dispatch and checklist item 6f all read it.Checklist item 6h cross-referenced itself. It now points at items 6c and 6f, and cites Hernán and Robins (2016) and Danaei et al. (2013) where it names inverse probability of censoring weighting.
The Table S1 caption no longer claims a hot-deck draw the plan may not have used. It names the plan’s
impute_fnand reports the hot-deck draw as the default.The job-log progress line no longer counts a step the caller never signalled.
with_progress()adds shutdown steps when the expression ends. A 200-step progressor signalled 100 times finished on101/200, and one signalled 0 times finished on1/200. The finish line now reports the count the caller reached, clamped to the total.
Breaking changes
The washout type
no_prior_interventionis nowno_prior_value, and its keyintervention_valueis nowvalue. There is no alias: a frozen specification that carries either old name stops parsing.The supplementary tables are renumbered, and the missing-data sheet is always written. There is no Table S0. The sheet is
Table S1 Missing data, and each combined-baselines sheet moves down by one toTable S2and after. A plan that filled nothing gets the sheet with one line, so the numbering does not depend on the data.Every
TTEEnrollmentserialised by swereg 26.10.18 or earlier is refused at load..TTE_ENROLLMENT_SCHEMA_VERSIONis now4L. 26.10.18 wrote3L, so an object that already carries thefill_summaryandps_fitfields is refused too. Re-run s1 to rebuild the object.
Documentation
Adding a public field changes the serialised bytes of every
TTEEnrollment. A byte digest taken across a release boundary therefore differs, and that difference is not a behaviour change.vignettes/r6-class-overview.Rmdshowed methods and arguments that do not exist.$rates()and$heterogeneity_test()both takeweight_col, and neither$rates(by = )nor$irr(formula = )exists.The
vignettes/tte-methods.Rmdsentence rewrite is deferred to a follow-up issue.The
@param memroxygen of$slurm_job()keeps its one-line description. The rationale for the"95G"default tracks one node and one study, so a help page is the wrong home for it.
swereg 26.10.18
New features
$compute_summary()counts the columns phase 3 adds, and what the framework removes. A logical column gets one row. A character or factor column gets one row per level, keyed<column>=<level>. A column with more than 100 distinct values is skipped and named in the meta’srandvars_counts_skipped. A batch whose meta predates the counts is backfilled from the skeleton on disk, with no phase replay. When the framework function returns aframework_removalsattribute,status.txtand the TSV report each step summed over the batches that carry it. Thestatus.txtlabel readsn_persons (any row, weekly or annual). It readn_persons (any weekly row)and counted both kinds.$s1_generate_enrollments_and_ipw()reports the cost of its pre-run delete. It printsCleared s1 work directory: <n> files, <size> in <s> sbefore the first sub-step. A run that inherits thousands of leftover chunk files can spend minutes inunlink(), and nothing in the log explained the wait.$slurm_job()asks Slurm for 95G by default, up from 85G. An s1 run peaked above the old request.
Bug fixes
setup_progress_handlers()repainted a progress bar into every job log. A batch job’s stderr is a file, and theprogresspackage repaints with a carriage return. One long run wrote 1 MB of repaint frames and a longer one wrote 7.2 MB, which madegrepandtailuseless. A non-interactive session now gets one plain line on stderr per interval, plus one line at the finish. Set the interval withoptions(swereg.progress_interval_s = ...); the default is 600 seconds. Theas.hms()deprecation warning goes with the old handler.The prevalent-user warning now measures coverage in the skeleton. It compared two column names before, so it warned whenever the washout column differed from the treatment column. A washout on a parent column can still be a new-user design. That holds when every week at the treatment level is also a week at the washed-out level. A name test cannot see that. The check moves from
tteplan_read_spec(), which reads no data, totteplan_validate_spec(), which receives the first skeleton batch. A washout covers the enrollment when every weekly row at the intervention level also holds one of the washout’s levels. A missing value counts as uncovered, and a multi-source washout covers through the union of its sources. A washout that names the right column at the wrong level now warns, and the warning reports the uncovered week count.options(swereg.warn_prevalent_user = FALSE)still silences it. Withglobal_max_isoyearweeksupplied no skeleton is loaded, and the build reportsprevalent-user check skipped: no skeleton loaded.tteplan_validate_spec()stopped on a multi-source washout in an enrollment. The enrollment-leveladditional_exclusioncheck tested one column name at a time, andsource_variableholds a vector. R raised “the condition has length > 1”. The check is now vectorised, as the globalexclusion_criteriacheck already was, and one message names every missing column.
swereg 26.10.17
New features
-
$s2_generate_analysis_files_and_ipcw_pp()stopped when a time-updated confounder was missing on a follow-up row. An annual register value that the source delivery does not carry for the last year of follow-up produces exactly that.NAthere gives anNAcensoring weight, andcumprod()carries it through the rest of the person-trial.$s1b_fill_followup_confounders()now carries the last observed value forward inside each person-trial, seeded from the.tte_entry__entry-window snapshot. s1d runs it between the imputation and the weights.tteenrollment_fill_summary()reports the filled rows and person-trials per confounder, and s1d stores that table onenrollment$fill_summary.
Bug fixes
$s2_ipw()reported nothing when the propensity model separated. A fitted probability at the boundary gives an inverse probability weight near 1e8, and the run continued.$s2_ipw()now recordsn_fit,rank,convergedandn_boundaryon the new$ps_fitfield. It warns when the rank reaches within one of the row count, or when any probability is at the boundary. It warns and never stops: at full scale a handful of boundary probabilities is possible, and s1 runs for hours.$irr()fit through zero events in one arm and returned a separated estimate. An outcome with no event in the comparator arm returned a very large ratio with a confidence interval. An outcome with no event in either arm returned a ratio near 1. Both now return theNArow withwarn = TRUEand a warning, exactly as$irr_by_subgroup()does for a zero-event stratum. Accept the consequence: an ETT with no events in one arm now reports NA where it reported a number.
Documentation
Two vignettes named methods that do not exist.
vignette("tte-workflow")andvignette("r6-class-overview")still listed$s1_collapse(),$s2_impute_confounders(),$s3_ipw(),$s4_truncate_weights()and$s5_prepare_for_analysis(), and called the imputation multiple. Both now name the shipped methods and the single hot-deck.vignette("r6-class-overview")also claimed that a skipped stage errors.$s2_ipw()before$s1_impute_confounders()leavesipwasNAon the person-trials it could not fit.The TARGET checklist described the imputation and said nothing about the fill. Item 7a-h paragraph 6g and item 11 now describe single hot-deck imputation at trial entry, carry-forward through follow-up, and
tteenrollment_fill_summary().vignette("tte-methods")sections 1.4, 1.5, 1.8 and 1.11 and the SAP-to-code table say the same.
swereg 26.10.16
Bug fixes
-
tte_stage()stopped withoutcome_vars cannot be emptyon every real plan.tteplan_locate_and_load()returns an object of classTTEPlan, soplan[[method]]dispatched to[[.TTEPlan, which calls$enrollment_spec(method). That call indexes the enrollment ids by a name and getsNA. No row then matches,outcome_varsis empty, and the stage method never runs. The lookup now uses.subset2(), which reads the binding without S3 dispatch. A test pins it against an object of classTTEPlan. The earlier tests built the plan as a plain list, which carries no[[method, so they could not see this.
swereg 26.10.15
Internal
-
Six sites read one column of the skeleton through a full-width row subset. Each one copied the whole table to reach
isoyearweek.min(skeleton[is_isoyear == FALSE]$isoyearweek)is nowskeleton[is_isoyear == FALSE, min(isoyearweek)], andadd_rx()takes the weekly column the same way. The answer is unchanged. The copy that goes away costs several GB per call on a wide skeleton.The sites are
add_diagnoses(),add_operations(),add_cods(),add_cancer_without_morphology(),add_quality_registry()andadd_rx().
swereg 26.10.14
Breaking changes
Every
add_*function now returns the skeleton, invisibly. Five returnedNULL:add_diagnoses(),add_operations(),add_cods(),add_cancer_without_morphology()andadd_rx(). A caller that assigns the return keeps its new columns even when the table has to grow. Code that assertedis.null(add_rx(...))now fails, andlapply()over anadd_*now collects tables rather thanNULL.options(datatable.alloccol)no longer controls whatqs2_read()restores. The reader used to read that option. It now restores 4096 free column slots for a skeleton and 1024 for every other data.table, whatever the option says. Raise the option and it changes what[.data.tablereserves, not what the reader restores.-
The applier now checks three things after every code entry. Every column the entry declares is present. The four structural columns satisfy
identical()against the ones the entry started with, attributes included:id,isoyear,isoyearweekandis_isoyear. No column that was there before is dropped, and none is replaced by a new vector. An entry MAY replace a column it declares.The check does not see a write INTO an existing vector. A registered function holds a reference to the skeleton, so it can always do that, as it could before this release. Register a row filter with
study$register_trim()rather than a filter inside an entry. A table a code entry returns is ignored unless it is the skeleton itself. The applier compares every column vector by address, so a table the entry grew is adopted and a table it built is not. An entry that ends on
dataset[, scratch := NULL]keeps working. An entry that writes its columns to a table it built loses them, and the expected-column check reports which.register_codes()stops whengroupsnames a group that is not in the study’sgroup_names.$load_rawbatch()reads one file per name there, so a name outside that set never arrives. A study that registered an optional, undelivered source now fails at registration rather than part way through phase 2.register_codes()andregister_derived_codes()stop when the entry would generate a column an already-registered entry generates.$drop_code_entry()removes exactly the columns an entry declares, so two owners of one column make a drop of either one delete the other’s output.
Bug fixes
Phase 2 lost every column of an entry once the skeleton ran out of column slots. A data.table gains a column in place only while its column-pointer vector has a free slot.
Skeleton$apply_code_entry()discarded the applier’s return value, so the grown table went with it. The applier now reserves the slots each entry needs, and the R6 holder assigns the return.A failed entry leaves the in-memory skeleton unusable. It MAY carry the entry’s writes and it MAY not, because the answer depends on whether the entry had to grow the table. Nothing is recorded,
$process_skeletons()never writes that batch, and the run halts, so no partial batch reaches a skeleton file.An entry whose groups held no row wrote no column, and the run continued.
Filter()dropped a zero-row table, so the entry’s function never ran. The applier now calls the function with that zero-row table, so the function’s own no-match value reaches the skeleton. A final check then holds every column.entry_columns()predicts.A derived code entry wrote zero columns, and the run continued. That branch called neither the allocator nor the post-condition check. Both are wired in now.
The contract check read the row count, which a swap survives. It now compares all four structural columns against the ones it took before the entry. A deleted person and a duplicated person no longer cancel out, and a
skeleton[i, isoyear := x]no longer passes. It also checks that every column present before the entry is present after it, which a.()projection used to defeat. One snapshot per entry is cheap relative to a code entry..tte_build_exclusion_specs()and.tte_build_confounder_specs()lost every column they wrote to a skeleton with no free slot. Both returned only their specification lists, so the caller kept a table withouteligible_isoyears,eligible_ageor any combined source column. Both now return the skeleton, and every caller assigns it.
Documentation
- Four homes state the return contract and the two slot figures:
?qs2_read,?Skeleton, everyadd_*@return, andvignette("custom-add-functions"). Each also states that a name taken before a growing call is stale.
swereg 26.10.13
Breaking changes
tteplan_read_spec()refuses every key.TTE_SPEC_SCHEMAdoes not name. A key swereg does not read used to pass in silence. The message names each refused path, and states the repair for it.inclusion_criteria$additional_inclusionis gone. Move each entry toinclusion_criteria$criteria. swereg never read the old path, so a criterion written there never restricted the study population. Each entry undercriteriadeclaresname,type: has_event, andimplementation$source_variable.
New features
inclusion_criteria$criteriarestricts the study population. The container is fixed:inclusion_criteriaholdsisoyearsandcriteria, and nothing else. Each criterion applies to every enrollment, and adds oneeligible_has_<variable>_<window>column thattteplan_apply_exclusions()combines intoeligible.Five consumers now render a global inclusion criterion: the spec workbook, the protocol table, the console summary, the TARGET checklist and the CONSORT labels. Each rendered
isoyearsalone before.vignette("tte-spec-schema")documents the schema. Its tables are generated from.TTE_SPEC_SCHEMA, which declares 140 key paths across 41 mapping contexts.
Bug fixes
-
TTEPlan$reload_spec()can no longer leak a structural change into a live plan. It warned that structural changes were not applied, then replaced the spec whole. The eligibility builder reads$spec, so cached eligibility kept the old criterion while reporting read the new one. The method now builds a candidate, checks it, and writes it only when no structural difference survives.
swereg 26.10.12
Documentation
vignette("custom-add-functions")gains a “Column-slot headroom” section. It records the silent column loss that 26.10.10 and 26.10.11 fixed..ensure_dt_alloc()is internal, so the section states what a user who writes their ownadd_*MUST do instead.CLAUDE.mdandvignette("builtin-add-functions")point at that section.
swereg 26.10.11
Bug Fixes
make_rowind_first_occurrence()and the sixskeleton_eligible_*()functions now reach the caller’s table when its data.table column slots run out. Past the last spare slot:=writes to a new object, and the caller keeps the old one. This is the defect 26.10.10 fixed in the eightadd_*functions. These seven were the last unguarded write sites.make_rowind_first_occurrence()needs two spare slots, not one. It holdstempbeside the new column. At one spare slot the caller kepttempand never got the new column. The next call then collided with the leftovertemp.skeleton_eligible_no_observation_in_window_excluding_wk0()holds a.temp_obs_*column the same way.
swereg 26.10.10
Bug Fixes
Eight
add_*functions now reach the caller’s skeleton when its data.table column slots run out. Past the last spare slot:=writes to a new object. The caller kept the old one and lost the columns in silence. The measured case was a skeleton of 1025 columns with 11 free slots and a LISA join of 59 columns. It killed the reclean atobject 'dispink04' not found..ensure_dt_alloc()inR/dt_alloc.Ris the one implementation.add_annual(),add_onetime(),add_quality_registry(),add_rx(),add_diagnoses(),add_operations(),add_cods()andadd_cancer_without_morphology()all call it. It grows the column list before the write, and writes the grown table back to the caller’s binding. R cannot grow a list in place, soaddress()on that table changes. The column vectors are shared, so nothing copies the data.A caller that passes an expression rather than a variable now gets a warning. The repair reaches a name, and a
$,[[or@chain that ends at a name.add_annual(f(x), ...)has no binding to write to, so it warns and returns the full table.
swereg 26.10.9
Bug Fixes
qs2_read()restores data.table over-allocation, so a:=inside a function now reaches the caller. qs2 does not keep the over-allocated column slots. A table read back from disk carried atruelength()of 0. The first:=on it inside a function then wrote to a shallow copy, and the caller never received the column. data.table 1.18.4 reports nothing. The generic reclean failed withobject 'dispink04' not found, becauseadd_annual()added no column to the caller’s skeleton.The repair covers a data.table at any depth.
qs2_read()callsdata.table::setalloccol()on the top-level object and on every element of a plain list. It also calls it on every field of an R6 object, public or private. There is no depth limit. It enters nothing else. A data.table inside an active binding, a function, a classed list, a non-R6 environment or an S4 object keeps atruelength()of 0.Every reader in
R/now carries the repair.R/held seven directqs2::qs_read()calls. Six moved toqs2_read(). The seventh, the meta-missing fallback in$skeleton_pipeline_hashes(), keeps the direct call, so that itscheck_version()cannot turn a stale-schema skeleton into an NA row. It takes the repair on its own. The three hand-writtensetalloccol()calls inRegistryStudy$load_skeleton(),.s1_load_skeleton()and.s1c_worker_impl()are gone. One implementation now serves them all.A rawbatch data.table handed to
add_annual()now receives itsisoyearcolumn by reference.add_annual()always wrote that column to itsdataargument. The write reached a shallow copy when the table came straight from disk. It reaches the caller’s table now.
swereg 26.10.7
Continuous integration
swereg passes the
static-checkslint gate of the shared workflow. The sweep fixed 941 findings in 93 of the 95 code files inR/. They were 626 missing explicitreturn()calls and 308stop()orwarning()calls withoutcall. = FALSE. The remaining 7 findings wereTorFin place ofTRUEorFALSE.max-cyclocompis set to 90, the measured ceiling inR/today. The house limit is 15, which is lintr’s default. TheTTEEnrollment <- R6::R6Class(expression atR/r6_tteenrollment.R:146runs 90, and 69 expressions in all exceed 15. The gate permits today’s worst case and fails on any increase.
swereg 26.10.5
New features
tte_stage()runs one target trial emulation pipeline stage. It replaces the body of a per-projects1.R,s2.Rors3.Rstage script with one call. Every argument in...MUST be named, because the three stage methods order their arguments differently.TTEPlan$slurm_job()returns abatchit::slurm_itdescription of one pipeline stage. It writes no file and it submits no job. The job body is oneRscript -ecall totte_stage().batchit::slurm_write()writes the bash, and a person runs the generatedsubmit.sh.
Breaking changes
-
tteplan_export_slurm()is removed. swereg exported it from 26.10.3, and no code in swereg or in the analysis projects called it.TTEPlan$slurm_job()withbatchit::slurm_write()replaces it.
swereg 26.10.4
Bug fixes
-
CONSORT rendering stops when the optional diagram stack is absent.
DiagrammeR,DiagrammeRsvgandrsvgsit in Suggests, so a plain install does not carry them..render_consort_sidecars()warned and returnedNULLbefore, and.plan_export_figure()returned the PNG path anyway. The caller then held a path to a file that was never written. The new error names the absent packages and thepak::pak()line that installs them.
Documentation
Roxygen markdown is on:
Roxygen: list(markdown = TRUE)inDESCRIPTION. A backtick span now renders as code, and[fn()]renders as a link. 49 of the 65 help pages changed.Eight unescaped
%characters in five help pages no longer truncate the text. An unescaped%starts an Rd comment.?tteplan_export_slurmended a sentence atstat -f -cand dropped the rest of the line.
Internal
test-imports_runtime_present.Rasserts the guard instead of a stale promotion claim.survival,DiagrammeR,DiagrammeRsvgandrsvgleft the required list, becauseDESCRIPTIONputs all four in Suggests. The file checks each remaining package againstDESCRIPTIONImports. It then drives.render_consort_sidecars()once per absent diagram package, and reads the error message.Two end-to-end CONSORT render tests pin the guard’s happy path — one through the render chain, one through a production
$export_tables()call — and a minimum artifact size.
swereg 26.10.3
New features
-
tteplan_export_slurm()writes a Slurm job chain from aTTEPlan. It writes one job script per stage, plus a driver that chains them with--dependency=afterok, and it never callssbatch.
Bug fixes
-
The prevalent-user warning now counts only an exclusion that targets the treatment variable. The guard tested the exclusion type before, so one
no_prior_interventionexclusion on any unrelated variable silenced it. An exclusion names its target columns insource_variable, whatever its type. A multi-source exclusion also matches through its derivedsource_variable_combinedcolumn.options(swereg.warn_prevalent_user = FALSE)still silences the warning.
Package check
R CMD check --as-cranreports 0 errors, 0 warnings and 0 notes. Version 26.10.2 reported two notes.Each undefined global is now declared in the function that reads it. Six internal functions and the two
text_colclosures carry a<sym> <- NULLline. The oldutils::globalVariables("i.irr_estimable_itt")call is gone.R/imports.Rkeeps two symbols package-wide instead. The check reports.as an undefined function, which no local binding satisfies. A local..cache_cols <- NULLwould make data.table warn on every call.survivalmoves from Imports to Suggests. Nothing inR/calls it.vignette("cookbook-survival-analysis")does.mgcvandsurveystay in Imports, andR/imports.Rgainsignore_unused_imports(). Every real call sits inside aTTEEnrollmentmethod. The scan behind the unused-Imports note does not read R6 method bodies.
Website
-
The pkgdown template package is renamed to
pptemplate.DESCRIPTIONConfig/Needs/websiteand_pkgdown.ymltemplate: package:both namepapadopoulos-lab/pptemplatenow. The house style itself is unchanged.
Continuous integration
A caller of the shared
papadopoulos-lab/pptemplateworkflow replaces.github/workflows/check-and-pkgdown.yml. The shared workflow runsloc-limit, thenR-CMD-check, thenpkgdown. The caller’sloc-allowlistnames the three files inR/already over 1000 code lines.rhub.yamlis unchanged.R/forest_plot.Rsplits into four files by role, and leaves theloc-allowlist. The renderers go toR/forest_render.R, the data assembly toR/forest_data.R, and the cell and header text toR/forest_format.R. Each of the four holds under 1000 code lines.R/r6_registrystudy.Rsplits into eleven files by role, and leaves theloc-allowlist. Twenty-four helper functions and two constants move to eight role-named files. SixteenRegistryStudymethods reach the class through$set(), fromR/r6_registrystudy_persistence.RandR/r6_registrystudy_report.R.DESCRIPTIONgains theCollate:field that@includegenerates, sor6_registrystudy.Rloads first. The largest of the eleven holds 675 code lines.R/r6_tteenrollment.Rsplits into fourteen files by role, and leaves theloc-allowlist. Forty helper functions and three constants move to ten role-named files.TTEDesignmoves whole toR/r6_ttedesign.R. FifteenTTEEnrollmentmethods reach the class through$set(), fromR/r6_tteenrollment_weighting.RandR/r6_tteenrollment_estimation.R. The largest of the fourteen holds 787 code lines.R/r6_tteplan.Rsplits into twenty-nine files by role, and theloc-allowlistis now empty. Eighty-eight helper functions and five constants move to twenty-two role-named files. Twenty-twoTTEPlanmethods reach the class through$set(), from sixr6_tteplan_*.Rfiles. Four tests that read the old file by path now name the file holding the code they audit. The caller workflow drops theloc-allowlistkey. The largest of the twenty-nine holds 695 code lines.
Internal
The three
RegistryStudycode-identity methods now delegate to plain functions inR/code_identity.R.$code_registry_fingerprints(),$randvars_hashes()and$pipeline_hash()keep their formals, their documentation and every hash value.tests/testthat/test-code_identity.Rtests the three functions behind them directly.The nine
TTEEnrollmentestimation members now delegate to plain functions inR/tte_estimation.R. The eight public methods keep their formals, their documentation and every estimate. The private.fit_irr()leaves the class and becomes.tte_fit_irr(), which takes the design as an argument.$table1()and the plan’s.s3_enrollment_table1()share one computation,.tte_table1_core(), so the two routes cannot drift apart.The eight
TTEPlanreporting and export members now delegate to plain functions inR/tteplan_reporting.RandR/tteplan_export.R. The six public methods keep their formals, their documentation and every artefact they write. The private.export_figure()and.export_table()producers leave the class and become.plan_export_figure()and.plan_export_table(), which take the plan as their first argument.
Documentation
Two false documentation claims are gone, and the TTE/ETT rule is written down.
vignette("tte-workflow")describedpipeline_hash()as a three-phase summary. It now names the five inputs the hash folds in, and says why the trim carries a field of its own.README.mddrops its “Known issues” tracker and returns to a landing page under 500 words. TTE names the method and ETT names one grid cell: the rule sits invignette("tte-nomenclature")and in the$add_one_ett()roxygen. No identifier changes.The 26.9.0
observed_varentry lists three row deletions, and that list predates the caller’s trim of 2026-08-20. The entry names first immigration, emigration and death. The trim of 2026-08-20 also deletes every person-week before birth. The list is one example of a trim, not the full set of deletions a trim may make.
swereg 26.10.2
Breaking. Two things MUST happen before the next run:
- Move any row deletion into a trim registered with
$register_trim(). - Regenerate
registrystudy.qs2. A study saved at schema version 5 stops on load and names the generator to re-run.
$process_skeletons() then rebuilds every stored skeleton once, because the phase order changed.
The code registry runs before randvars
The per-batch pipeline is now framework -> trim -> codes -> randvars. A randvars step MAY read a code registry column. That was impossible before, and it is the capability this release unlocks.
Every randvars step’s hash now folds in the code registry fingerprints. So one code entry edit replays the whole randvars sequence against the new columns.
Skeleton carries a new phase_order field, and .REGISTRY_STUDY_SCHEMA_VERSION is now 6. A skeleton written under the old order reads NULL in that field. The rebuild is the only correct answer: no rewind can add a value the old order never wrote.
A registered code function’s body now reaches its fingerprint
$code_registry_fingerprints() hashed an entry’s codes, label, groups, fn_args and combine_as, and never its fn. So an edit to a registered code function changed the column it wrote, moved no fingerprint, re-applied nothing and replayed no randvars step. The skeleton kept the old column and reported nothing.
The fingerprint now folds in that function’s body and formals. Every fingerprint in every study moves once, so every code entry re-applies and every randvars step replays. This release already rebuilds every skeleton, so that costs nothing extra today.
Two inputs still sit outside the hashes: the rawbatch data, and whatever a registered function calls or reads from its environment. A hash covers a function’s own body and formals, and follows no call into a helper.
A function’s hash no longer depends on how it was parsed
.hash_function() read body(fn) with the srcref attached. R keeps a srcref when keep.source is TRUE, which is the interactive default, and drops it under Rscript, which is the default there. The same function therefore hashed two ways.
Register a framework function in RStudio, then run the pipeline under Rscript, and every batch rebuilt. utils::removeSource() now runs before the hash, so the two sessions agree.
Row deletion belongs to a registered trim
$register_trim(fn) registers at most one trim function, of signature (skeleton, batch_data, config). The function returns a data.table. The trim runs on a fresh base, after the framework and before the code registry, so every later phase sees the rows it leaves behind.
It is the one place in the pipeline that may delete skeleton rows. A code entry or a randvars step that changes the row count now stops the run, and the error names the registration to edit.
To migrate, take the row filter out of the randvars step or add_* function that carries it. Register that same predicate with $register_trim(). Only the filter moves; the rest of the step stays where it is.
A trim edit rebuilds the base of every batch, because a deletion cannot be rewound. A first trim and a removed trim do the same. Skeleton carries the trim’s identity in a new trim_fn_hash field.
Skeleton$refresh_code_entry_counts() recomputes every applied code entry’s per-column counts from the final data, and $save_skeleton() calls it before it writes either file. The counts therefore describe the skeleton that gets written.
Documentation
-
vignette("skeleton-pipeline")is now titled “The skeleton pipeline”. It takes the four phases in execution order and gains a phase-1b section. Two false claims are gone: that the code registry runs after randvars, and that a randvars step cannot read a code column. -
?RegistryStudyand?Skeletonwere regenerated.$register_trim(),$randvars_hashes()andSkeleton$refresh_code_entry_counts()reach a help page for the first time. -
vignette("caching-and-resume")takes all four phases in the replay decision.vignette("r6-class-overview")names the trim.
swereg 26.10.1
Documentation
-
The TTE vignettes describe current behaviour only. Three passages narrated a superseded implementation instead of the one that ships.
vignette("tte-timing")states thatenroll()reads the weeklytime_treatment_varsequence, and that neither follow-up end is rounded to a band boundary.vignette("tte-methods")andvignette("tte-nomenclature")state what the band width trades, with no reference to an earlier design. -
vignette("tte-methods")section 1.1 carries a worked example of the eligible-week filter. A table follows one person through a four-week band, and shows why a lifetime washout on both arms assigns that person to the treatment that person started first. It also states the one case a shared exposure column cannot record: both treatments covered in the same week. Behaviour is unchanged. -
vignette("tte-methods")section 4.2 “Provenance notes” is removed. It restated the 26.7.3 event-priority change and theadmin_censor_varerror. This file records both under swereg 26.7.3, in more detail. Section 4.3 is renumbered to 4.2.
swereg 26.10.0
Breaking. Every spec MUST be edited. The enrollment key treatment.implementation.matching_ratio is now treatment.implementation.comparator_to_intervention_ratio. The number is unchanged: the draw takes that many times a trial’s count of intervention individuals. tteplan_read_spec() stops on a spec that still carries matching_ratio, and the message names the new key. swereg does not accept the old key.
To migrate a spec, rename the key in every enrollment. A spec version is the record of a completed run. Copy a released version to a new version. Do not edit a released version.
The generated methods text and the CONSORT figure call the comparator draw incidence density sampling
The generated manuscript methods text, the protocol table and the CONSORT diagram called the comparator draw “matching”. swereg runs no matching. The draw is incidence density sampling, and every generated artefact now says so.
The draw takes one sample per sequential trial. Its size is comparator_to_intervention_ratio times that trial’s count of intervention individuals, capped at the comparators the trial holds. One sequential trial is one entry band of period_width weeks, which defaults to 4. The sampling is stratified by that band, and the draw reads no other variable.
The draw attaches no comparator individual to an intervention individual, so no matched set exists, and nothing in the analysis conditions on one. survey::svydesign() clusters the variance on person. Where more than one sequential trial contributes, the trial enters the outcome model as a covariate, never as a stratum. A person can be an intervention individual in one trial and a comparator individual in another.
Confounding adjustment is unchanged. It is by inverse probability weighting on the covariates taken at the recruiting week.
- TARGET item 6c and item 7c name incidence density sampling. Each paragraph states the trial-level draw size, the cap, and the entry band width. Each also states that the draw forms no matched set.
-
TARGET item 7a prints the period width instead of the literal
period_width. Two generated sentences leaked the variable name into manuscript prose. -
The protocol table gains five assignment rows.
Comparator draw:names the scheme.Comparator draw size:names the trial-level count and its cap.Comparator draw stratum:names the entry band and its width.Comparator pairing:records that none exists.Confounding adjustment:names the weighting step and the recruiting week. -
The protocol table reads
Comparator-to-intervention ratio:andComparator draw seed:. They readMatching ratio:andMatching seed:. -
Every generated ratio prints comparators first, as
2:1. The protocol table, the console spec summary, the Excel spec sheet and TARGET item 6b printed1:2, against2:1in the vignettes. The label now names both sides, so the bare digits cannot read either way. The illustrative ratio invignette("tte-nomenclature")reads5:1. -
The CONSORT node reads
Enrolled after the comparator draw, then the scheme and the stratum on its own line. It readEnrolled after matching. -
The cohort-flow step is
enrolled_after_comparator_draw. It wasenrolled_after_matching. Its change label isnot drawn (comparator draw), which wasnot selected (matching). -
Six vignettes stop calling swereg’s own draw matching:
tte-timing,tte-methods,tte-workflow,tte-nomenclature,tte-methodologyandr6-class-overview. Each now names the scheme and the absence of a matched set. -
vignette("tte-nomenclature")keeps the literature alternatives. Propensity score matching and the no-matching IPW design are real alternatives in other people’s designs, so those two entries keep the word.
No estimate moves. The draw, the seed and the ratio are one operation on one number.
$get_matching() keeps its name
$get_matching(), plan$enrollment_counts[[eid]]$matching and the matching counts slot keep their names. They are a stored schema and a public accessor. A rename there breaks a saved plan and every consumer of one. Their documentation now calls the operation the comparator draw.
The release-version test tracks the version instead of pinning it
tests/testthat/test-landmark-migration.R compared DESCRIPTION and the newest NEWS.md heading against the literal 26.9.0, so it turned red at the first bump after 26.9.0. Release 26.9.1 carries it red. Its skip_if_not() fires under R CMD check, so CI reported green. The block now asserts that the two agree, and names neither.
swereg 26.9.0
Time zero moved to the landmark. Every intention-to-treat estimate and every per-protocol estimate moves. Re-run every analysis an earlier release produced, and re-create every derived file.
The estimand changed with it. A 26.9.0 estimate does not correct a 26.8.21 estimate. The two answer different questions, so a reader MUST NOT compare them as one number before and after a fix. vignette("tte-timing") states the new estimand, the interval convention and the limits of both.
An outcome lands in the week it happened, not at the stop of its band
An outcome inside a partial terminal band was recorded as loss to follow-up and left the numerator. Re-run every per-protocol and intention-to-treat estimate. The production skeleton deletes every person-week after death and keeps the death week. A death is therefore a record end inside a partial band, and that is the case this repairs.
-
weeks_to_eventis exact to the week.enroll()reads the outcome week from the weekly rows and writes it asweeks_to_event_<outcome>, one column per outcome. It was the stop of the band the outcome fell in. -
The event indicator holds again.
s5_prepare_outcome()setseventfromtstop == weeks_to_event. A row clipped to week 10 compared itself against band stop 12 and read 0. -
The boundaries now share one resolution.
weeks_to_protocol_deviation,weeks_to_record_end,weeks_to_lossand the administrative end were already exact. An event that ties with a record end wins the tie. -
A person-trial stops at its event week. The terminal row clips there, so its
person_weeksno longer runs to the end of the band. - A panel built outside
enroll()carries no weekly boundary. It keeps the band-collapsed read and the numbers it had.
A survival risk set spans the time point instead of stopping at it
Every survival curve and every risk difference moves on a panel that holds a clipped terminal row. Re-run any figure or table an earlier release produced from $survival_curve() or $risk_difference().
-
A row is at risk at every time it spans. The risk set at time
tholds every row withtstart < t <= tstop. It held only the rows that stopped attbefore. That was harmless while every stop sat on the band grid.$s4_prepare_for_analysis()clips the terminal row at the exact censoring week, so a stop now falls between two band boundaries. - The event still lands at the stop of its own row. The risk set spans and the event does not. That asymmetry is the half-open convention the panel is built on.
-
The risk set stays a weighted COUNT of the person-trials at risk. It is not a sum of person-time.
$rates()owns the person-time quantity, and neither it nor theoffset(log(person_weeks))of a Poisson model changes. -
The bootstrap resamples the same risk sets as the point estimate.
$risk_difference()builds one denominator per person-trial and band from the spanning rows. The point estimate and every replicate read it. -
Survival carries forward where an arm holds nobody at risk. The hazard is undefined there and reads
NA. The reported survival is the latest exact value. An arm that runs out of follow-up first no longer turns the rest of its curve missing. - Both methods report one row per arm and reporting time. An arm that holds no row of its own at a time now gets a row there.
- A panel with no
tstartcolumn keeps the numbers it had. Without a start column a row states no interval, so it is read as covering the band that ends at its own stop.
The censoring weight is complementary log-log, offset by person-time, and lagged
Every per-protocol estimate moves. A weight multiplies every row, so re-run any per-protocol analysis an earlier release produced.
-
The censoring model is complementary log-log with an
offset(log(person_weeks)). It was logistic with no offset. For one linear predictor, the probability of staying uncensored over four weeks is now the one-week probability to the fourth power. A clipped terminal band no longer carries a whole band’s censoring risk. -
The cumulative product is lagged. The weight on band
kis the probability of remaining uncensored through the START of bandk, so the product stops at bandk - 1. The first row of every person-trial then weighs exactly 1. The censored band stays in the risk set in this release. The inclusive product swereg inherited counted that band’s own censoring probability inside its own weight. - The numerator is a second fitted model. It carries the same band-start and trial-index terms as the denominator, and it drops the confounders. It was the empirical mean of the denominator predictions within a band and arm.
- The model reads the band start and no longer the band stop. The weight applies through that start, so the start is the time the model conditions on.
- A stratum that cannot be estimated stops the run. Earlier releases substituted the arm’s marginal censoring rate when a stratum held no censored row, held no uncensored row, or held fewer than 10 rows. A stratum with no censored row is now exact: every uncensoring probability is 1, so every weight is 1. A stratum with no uncensored row stops, and so does a model that cannot be fit.
-
estimate_ipcw_pp_with_gam = FALSEfits a spline of the band start. It fitssplines::ns(tstart, df = 3)at four or more distinct starts, a factor at two or three, and no time term at one. It fitted a linear term before. - A zero-width row stays out of the offset, because
log(0)is-Inf. It holds no person-time, so nothing can censor it, and its uncensoring probability is 1.
vignette("tte-methods") section 1.5 states the model and the weight.
An enrollment declares how observation is encoded
An enrollment now states how the data records that a person was under observation in a week. swereg could not tell an unobserved person from one who was out of arm or ineligible before. A trimmed skeleton hid the gap: an absent week reads exactly like an observed comparator week.
observed_var is a flat key on the enrollment. It takes one of two forms.
-
observed_var: {column: rd_observed}names a real logical person-week column. -
observed_var: {sentinel: row_presence}asserts that the caller already deleted every unobserved person-week. A row then exists if and only if the person was observed that week.
Use the sentinel when the skeleton already deletes every person-week the person was not under observation. The production skeleton is one example. It deletes every person-week up to and including first immigration, every person-week on or after emigration, and every person-week after death. It keeps the death week itself. A real observed column there would hold TRUE on every retained row. It could not represent an absent week. Row presence as a silent proxy stays forbidden. The sentinel is what makes the assumption explicit and testable.
Two more flat keys carry the arm tolerances: intervention_tolerance_weeks and comparator_tolerance_weeks. Each MUST be a whole number of at least 0. Each defaults to 0, which is what every earlier release did.
enrollments:
- id: "01"
name: "Statin initiation vs none, age 40-75"
observed_var:
sentinel: row_presence
intervention_tolerance_weeks: 0
comparator_tolerance_weeks: 0Every enrollment MUST declare observed_var, and this breaks every existing spec. tteplan_read_spec() stops on an enrollment that omits it. There is no exemption for an older spec. A spec that cannot say who was under observation carries the immortal-time defect silently. It looks exactly like a spec that can.
To migrate a spec, copy it to a new version and add the key to every enrollment. Never edit a released spec version. That version is the record of what produced a run.
tteplan_read_spec() rejects a declaration that gives both column and sentinel, a declaration that gives neither, and a sentinel name swereg does not know. It reads no data, so it cannot check a named column. tteplan_validate_spec() runs that check against the skeleton: the column MUST exist and it MUST be logical.
The three fields travel the whole chain. tteplan_from_spec_and_registrystudy() writes them into the ETT grid, $enrollment_spec() reads them back, and [TTEDesign] carries them. The s1 column allow-lists keep a named observation column, so the s1b and s1c workers can read it.
$reload_spec() calls all three fields structural. Each one changes who is enrolled and when they are censored, so a cached run cannot take a new value without a re-run.
Already-expanded trial data MAY leave observed_var unset. One row there is one trial and not one week of observation, so there is nothing for the field to encode.
Enrollment qualifies every person-band at its landmark
The candidate table handed to comparator sampling now holds only person-bands that are under observation and event-free at the landmark. Two defects went with the old behaviour, and each one changed a published number.
A woman with an outcome INSIDE her entry band enrolled into the intervention arm when she started treatment later in that band. The event sat at tstart = 0, and the initiation came after it. That is immortal-time attribution, and removing it is the reason this step exists.
A woman with entry-window rows but no row at the landmark reached the candidate table. Panel expansion dropped her later. The pipeline counted her as enrolled first. She took a comparator slot, she changed the requested comparator count, and she moved the seeded draw.
The landmark, and the two statements
The landmark of a person-band is the week that closes its entry band. Band b covers week indices b * period_width to (b + 1) * period_width - 1. Its landmark sits at week index (b + 1) * period_width, the first week of band b + 1. Week indices are positions in cstime::dates_by_isoyearweek, the scale trial_id already reads.
A person-band qualifies when both statements hold.
- The person is under observation at the landmark.
- No outcome occurrence stops at or before the landmark.
A week is a half-open interval, so an occurrence in week w stops at w + 1. Statement 2 therefore covers every week of the entry band, and every week before it.
observed_var decides statement 1. The row_presence sentinel reads the row being there as the observation. A named column MUST hold TRUE on that row.
Statement 2 reads EVERY column in design$outcome_vars, and not the one outcome a later step analyses. One enrollment serves several outcomes. $enrollment_spec() collects every ETT that shares an enrollment_id, and the s2 worker fans out over them. One enrolled set has to be event-free for all of them.
Eligibility stays a baseline property
eligible is assessed on the entry band, by .band_baseline_treatment(). It is NOT read again at the landmark, and reading it there would empty the intervention arm.
swereg wants a new-user or washout exclusion on the treatment variable. tteplan_read_spec() warns when an enrollment declares none. That exclusion sets eligible to FALSE from the week after initiation. An initiator starts inside her entry band, and her landmark always falls after that week. She is therefore ineligible at her own landmark by construction.
Measured on the ttm_skeleton() fixture in test-s1a_declared_outputs.R: 21 of 21 intervention person-bands were ineligible at the landmark. Of the 361 comparator bands that reached a landmark, 0 were ineligible there.
Sequential-trial designs assess eligibility at the start of a trial’s eligibility window. They assess survival and event-freedom through the grace window (Danaei et al. 2013, Caniglia et al. 2023). swereg follows that split.
recruit_week_index names the week that recruited each person
.band_baseline_treatment() now also returns recruit_week_index. It names the week that recruited the person into that band. It is the earliest week the classifier reads, which is the earliest week that is both eligible and in an arm.
- For an initiator that is her initiation week.
- For a comparator that is her first eligible comparator week.
The rule is symmetric across the arms. No rule keyed to initiation can be.
This is where eligibility is assessed, and it is true there by construction. Eligibility is part of what makes a week survive the classifier’s mask. No eligibility criterion is left to re-assess at the landmark.
Both enrollment paths carry it, because both call .band_baseline_treatment(). In the s1a scout it travels into the tuples, through the comparator draw, and into enrolled_ids on disk. enroll() puts it on the entry rows of the direct path and the pre-matched path.
Week indices are positions in cstime::dates_by_isoyearweek, minus one. That is the scale trial_id reads, so recruit_week_index and the landmark (trial_id + 1) * period_width compare directly.
min() is order-independent, exactly as any() is, so this adds no sort. It reads isoyearweek as a string. Every week in cstime::dates_by_isoyearweek matches YYYY-WW with a zero-padded week, measured across all 10,436 of them, so the strings sort chronologically.
recruit_week_index reports WHEN the person qualified. The entry-window snapshot below reads her covariates at that instant.
Where it runs, and why the position matters
Qualification runs after the arm classification and before the comparator draw. Both enrollment paths apply it.
-
.s1a_finalize_on_skeleton(), the production scout..s1b_worker()never reads a person-week, so the drop happens while the weekly data is in hand. -
enroll()Phase C, the directTTEEnrollment$new(..., ratio = )path.
The position carries two properties. Attrition reports both arms, because each band already carries one. Sampling refills the ratio from qualified comparators, so an unqualified woman no longer shrinks the matched set.
The cascade says why
Three criteria join the CONSORT attrition table: landmark_candidates, landmark_observed and landmark_event_free. Each count is cumulative, and each row splits into the intervention and comparator arms. They carry the columns .s1_compute_attrition() already writes, so the two tables stack and .s1b_worker() sums them unchanged.
TTEEnrollment$landmark_attrition is a new field carrying the same table for the direct path. It stays NULL in pre-matched mode, where the scout already qualified the ids.
Two consequences to expect
The last band of a skeleton enrolls nobody. No week follows it, so it has no landmark. A trial whose landmark falls past the end of the data has no follow-up to contribute.
Qualification runs only when the design declares observed_var. A design that declares none cannot say whether an absent week is an unobserved week or a week outside the study. tteplan_read_spec() makes the declaration mandatory, so every spec-driven enrollment qualifies. A [TTEDesign] built by hand without observed_var does not.
This step removes the person-bands that cannot qualify. The next section re-bases follow-up on the landmark, which is what removes the immortal time those bands carried.
Time zero is the landmark, and baseline covariates are read at the recruiting week
Every ITT and per-protocol estimate moves. The trial panel now opens one band AFTER the entry band, so the entry band contributes no follow-up. It used to open at the entry band, and a woman who initiated in week 3 of a four-week band carried three immortal weeks. Landmark qualification makes the whole entry band immortal: she must reach the landmark, event-free and under observation, to enroll at all.
What the panel looks like now
entry_band_id stays on the panel and names the trial. trial_id names the follow-up band, and the first row of every person-trial holds trial_id == entry_band_id + 1 and tstart == 0.
Each confounder reaches the panel twice.
-
.tte_entry__<v>holds the value at the recruiting week. That is the earliest week of the entry band that is both eligible and in an arm. -
<v>holds the time-updated value of the follow-up band, exactly as before.
The first week of the entry window is the wrong instant to read. A woman need not be eligible there, and she need not be in an arm there.
.tte_entry__ is a reserved prefix. [TTEDesign] stops on a confounder name that takes it.
What reads which
$s2_ipw() and $table1() fit and tabulate .tte_entry__<v>, under the plain name inside a local table. Time zero moved, so the tstart == 0 row now holds the landmark-band value, and reading it there would adjust for the wrong instant. The plan’s Table 1 worker takes the same route, and the two MUST agree.
$s6_ipcw_pp() still reads the time-updated <v>, because censoring depends on what is true during follow-up.
$s6_ipcw_pp() now stops when a time-updated confounder is missing on the rows it fits. It names the confounder, the rows and the person-trials. An NA there used to make stats::predict() return NA, and cumprod() carried that NA through the rest of the person-trial into the survey fit. swereg MUST NOT substitute the .tte_entry__ value: that value describes the recruiting week.
The old call hid the gap rather than filling it. $s1_impute_confounders() update-joins the baseline value onto every row of a person-trial, so an imputed confounder came out NA-free and flat across follow-up.
impute_fn now receives the .tte_entry__ names. Imputation is name-list driven, so the old call left the snapshot unimputed and imputed a column no adjustment step reads. It MUST impute only the columns it is given, and it MUST NOT overwrite the follow-up value.
Two consequences to expect
A person-trial whose follow-up band is absent from the data contributes no panel row. The old expansion gave it the entry band. That row was immortal time, and it was the only row such a trial had.
A panel with no recruit_week_index keeps the old read. A caller who builds enrolled_ids by hand, outside the plan chain, gets no snapshot. $s2_ipw() then falls back to the follow-up column. Every spec-driven enrollment carries the column, on the direct path and the pre-matched path.
Protocol deviation is read from the weekly assessments
Every per-protocol estimate moves. Deviation used to be decided from the band-collapsed treatment value, which is the LAST week of the band. A woman’s verdict followed where her weeks fell against the calendar grid, and not what she did. Two women who behaved the same way could get opposite verdicts.
enroll() now reads the weekly sequence itself. It writes one exact boundary per person-trial into weeks_to_protocol_deviation, and $s4_prepare_for_analysis() reads that column instead of recomputing.
Five patterns, and what each one used to give
Each row is the weekly assessment of one intervention woman across one band of four weeks, under a tolerance of 0.
| weekly pattern | collapsed value | old stop | new stop |
|---|---|---|---|
{T,T,F,F} |
FALSE |
end of week 4 | end of week 3 |
{T,F,T,T} |
TRUE |
n/a | end of week 2 |
{T,F,F,T} |
TRUE |
n/a | end of week 2 |
{T,NA,T,T} |
TRUE |
n/a | end of week 2 |
{T,T,T,NA} |
NA |
end of week 4 | end of week 4 |
Three of the five hid the switch completely. The last row is the one the old read already got right.
The rule
Discordance is arm-specific. An assessment is discordant when time_treatment_var does not hold the assigned arm of that person-trial. NA is discordant in both arms.
A tolerance is the number of CONSECUTIVE discordant assessments an arm allows, and a concordant assessment resets the run. For tolerance k, follow-up stops at the right edge of the (k + 1)th consecutive discordant week. A run that starts at week u0 stops at (u0 + k + 1) - L, where L is the landmark week. A tolerance of 0 stops at the first discordant week.
intervention_tolerance_weeks and comparator_tolerance_weeks carry the two values, and each one applies to its own arm.
A run that starts before the landmark counts only its weeks at or after it.
Loss of observation is never tolerated
An internal gap in the weekly sequence stops follow-up at the first absent week. The person may return in a later week. She is still censored at the gap. No tolerance applies, because loss of observation is not discordance.
Under the row_presence sentinel an absent week is an absent row. A band that loses one of its four weeks still reaches the panel, so the band-level read could not see the gap at all.
A record that simply ends carries no internal gap. weeks_to_loss already reports that case, and it reads the panel.
The boundary can fall inside a band
The panel is still one row per person-trial-band. Nothing is expanded weekly.
Every band before the boundary is complete follow-up, and the band that reaches it carries the censoring. $s4_prepare_for_analysis() clips that band at the boundary and keeps it. The next section covers the clipping.
Two limits to expect
The boundary needs observed_var. A design that declares none cannot say whether an absent week is an unobserved week or a week outside the study. It keeps the band-collapsed read. Every spec-driven enrollment declares the field.
A trial panel handed in directly keeps the band-collapsed read. One row there is one band, so there is no weekly sequence left to read.
Person-time is the exposure contributed, and the censoring row is kept
Every rate, every incidence rate ratio and every Poisson offset moves. The denominator used to be the number of source weeks the band collapsed, and $s4_prepare_for_analysis() then deleted the whole censoring row. A woman who deviated in week 2 of a four-week terminal band was billed for four weeks, and then lost all four.
s5_prepare_outcome() now clips the terminal row at the exact boundary, and sets person_weeks to tstop - tstart. The row stays in the analysis data. It carries the exposure before the boundary and nothing after it.
What the boundary is
Follow-up stops at the earliest of five events.
- The first outcome event.
- The first protocol deviation.
- The first observed loss.
- The administrative end.
- The requested follow-up end.
Priority runs in three levels. An outcome event beats everything. A protocol deviation and an observed loss come next. An administrative or requested end comes last.
An event that stops in the deviation band wins that band. The row counts as an event and not as a censoring, and the deviation does not clip it. That rule is unchanged since 26.7.3. The row stops at the event week itself, which can fall inside the band.
A record that ends inside a band bills only the weeks present
weeks_to_loss reports a record that stops before the planned end. It used to read .max_tstop, which is the stop of the LAST BAND. swereg credited a record that ended inside a band with weeks the person was never observed for.
enroll() now writes the exact week into weeks_to_record_end, read from the weekly sequence before the collapse. A woman observed for 10 follow-up weeks under a four-week band bills 10 weeks, and no longer 12.
The meaning of weeks_to_loss does not change. Only its resolution moves from the band to the week. A panel handed in directly carries no weeks_to_record_end, so it keeps the band-level read.
A record that reaches the end of the panel sets weeks_to_record_end to NA. That woman completed the follow-up the panel holds, so this boundary never censors her.
The boundary needs observed_var, exactly as the deviation boundary does.
The administrative and requested ends are exact
Neither is rounded to a band boundary. A six-week requested follow-up stops at week six, and a woman in a four-week band keeps the two weeks that used to disappear.
weeks_to_admin_end also moves by one week. difftime() counts the whole weeks BETWEEN the baseline week and the administrative week. The person is under study to the end of the administrative week itself, so the stop is one week after the count.
The warning about short trials changed with it. Only a trial that enters at or after the administrative week is dropped now.
Why the censoring row is safe to keep
The stated reason for deleting it was the deviated regime. A censoring row observes that regime, and a downstream outcome regression then attributes its outcome to the baseline treatment.
Clipping removes that risk at the source. The retained row stops at the exact censoring week, so it holds no time under the deviated regime. It also carries no event, because an event in the same band wins the band and clears censor_this_period.
$s6_ipcw_pp() still fits on the censoring rows, exactly as before. It ran before the deletion, so its inputs do not change.
Every stop is exclusive, and the convention is now stated once
[tstart, tstop) is the interval, and tstop - tstart is the duration. The rule held throughout the code and appeared in no document.
TTEDesign now carries an interval convention section, and TTEEnrollment and TTEPlan inherit it. tests/testthat/test-interval-convention.R pins the five boundaries: weeks_to_event, weeks_to_protocol_deviation, weeks_to_loss, weeks_to_admin_end and weeks_to_record_end. Each fixture places its boundary where the exclusive and inclusive readings disagree.
No behaviour changes. The audit of the five boundaries, and of every + 1L and - 1L in R/r6_tteenrollment.R and R/r6_tteplan.R, found no further defect.
Three TTE schema versions moved to 3
.TTE_DESIGN_SCHEMA_VERSION, .TTE_ENROLLMENT_SCHEMA_VERSION and .TTE_PLAN_SCHEMA_VERSION are now 3L.
An object saved by an earlier release no longer loads. TTEDesign$check_version(), TTEEnrollment$check_version() and TTEPlan$check_version() each stop with a migration message. qs2_read() calls the check for every R6 object it reads, and tteplan_load() calls it too. Re-run the project’s s0_init.R to build a new plan.
A version-2 object MUST NOT be reinterpreted. Its tstart == 0 rows are entry band rows, and a 26.9.0 reader would take them for landmark rows. The two earlier classes warned and continued, which is what made that silent reinterpretation possible.
swereg 26.8.21
A band with no eligible in-arm week enters neither arm
Enrollment reads only the weeks of a band in which the person is eligible and the treatment column holds TRUE or FALSE. It drops every other week of the band first. It then applies three rules.
- The person is an initiator when at least one week it reads holds
TRUE. - The person is a comparator when every week it reads holds
FALSE. - The person-band is ineligible when it reads no week at all.
Rule 3 changes who enters a trial, so it can change an estimate. The s1a scout path classified such a band as a comparator before. That path called any(x, na.rm = TRUE), and any() reports FALSE after it removes every value. The band now yields no row at all.
.band_baseline_treatment() is the one function that decides the rule. The s1a scout path and the direct enroll() path both call it, so the two paths cannot disagree. The direct path read first() off the earliest eligible week before, so its classification also changes.
- A person who starts the intervention in week 3 of a four-week band was a comparator. That person is now an initiator.
- A band whose first eligible week is out of arm entered neither arm. swereg drops that week now, and classifies the band from the weeks that remain.
- A band whose eligible weeks are all out of arm entered neither arm, and it still enters neither arm.
first()returnedNAthere, which matched neither arm.
The drop comes first, so an out-of-arm week neither creates nor prevents a comparator classification. A band of FALSE, NA, FALSE, FALSE is a comparator band. A band of NA, TRUE, FALSE, FALSE is an intervention band.
Time zero stays the start of the entry band. swereg attributes initiation anywhere in the entry band to that week. The band therefore carries residual within-band immortal time of at most period_width - 1 weeks.
.attrition_overall() returns NULL when a criterion carries no global row
That renderer reads the global attrition rows and nothing else. Every criterion MUST carry a global row. The renderer returns NULL when one criterion does not.
A per-trial fallback stood there and is removed. The fallback summed the per-trial rows, which counts a person once per sequential trial she enters. It reported that sum under a unique-persons heading, where the real unit is person-trials. Under a mixed input the sum sat one row away from a uniqueN count, so the CONSORT delta between the two rows could be negative.
An affected enrollment loses three outputs.
- The workbook holds no
Attrition_<id>sheet. The export writes no CONSORT PNG and no CONSORT PDF for that enrollment. - The provenance table of contents names no such sheet. One condition gates the sheet and its table-of-contents row now: the return value of
.write_attrition_sheet(). - The combined-baseline sheet (
Table S<n>) prints no cohort summary sentence. That sentence reported per-trial sums under a unique-persons label.
An attrition table written before the global rows existed reaches this state. A missing number is safer than a wrong one.
Documentation, examples and shipped data
- The vignettes state the mixed-band classification rule precisely.
vignette("tte-methods"),vignette("tte-workflow")andvignette("tte-nomenclature")say which weeks of a band the rule reads, and which it drops.TTEEnrollmentgains a Baseline treatment section that states the same rule, and$print_target_checklist()states it too. - The grace-period wording agrees across every statement. swereg implements no grace period.
period_widthgives within-band slack for the timing of initiation at enrollment, and nothing else. Deviation after the entry band censors per-protocol follow-up.vignette("tte-nomenclature")calledperiod_widtha grace period before, and the checklist that$print_target_checklist()prints made the same claim. -
vignette("tte-nomenclature")records that band boundaries are anchored to a fixed calendar origin, and not to the first week of a study. -
example/anddev/no longer name a collaborator or a private file path. The file server addresses are example addresses now, and one example script is renamed. The maintainer’s own name stays inexample/. - The documentation, the tests and the fixtures use unrelated codes in place of the study-specific ones.
-
dev/generate_fake_data.Rcallsset.seed()in each generator that draws random numbers, and each of those generators takes its ownseedargument. All six datasets underdata/are regenerated from that script. Five change in value.fake_person_idsdoes not change, and every row count stays the same.
swereg 26.8.20
A risk-difference result carries its statistical decisions as data
$risk_difference() returns two more columns. nnt is the signed number needed to treat, which is -1/rd. nnt_direction is the benefit-or-harm decision, and it reads "benefit", "harm" or NA.
.tte_nnt_from_rd() makes that decision once, beside rd, from the same numbers. Every formatter now reads the stored column. The cell that renders the number needed to treat tested the sign of its own input before. Each formatter was therefore its own decision-maker, and nothing forced two of them to agree about one band.
The cell builder takes the direction as a required argument. It has no default. A caller that cannot supply a direction gets an error, because a silent fall back to the sign is the defect this repairs.
The value stays signed throughout. abs() appears nowhere in the risk-difference or number-needed-to-treat arithmetic. A magnitude that lost its sign cannot separate benefit from harm.
The decision travels with the cached risk-difference row
The forest export path caches one risk-difference row per emulated trial onto plan$results_ett. That row now carries nnt and nnt_direction, copied off the curve. The figure reads the stored direction from the row. It no longer rebuilds one from rd.
The internal number-needed-to-treat helper reports no direction at all. It returns the magnitude and the interval only. One function decides the direction, one column stores it, and every reader reads that column.
A result cached before these columns existed renders no benefit-or-harm label. It does not error, and it does not fall back to the sign of rd. The two decision columns are exempt from the required-column check for that reason. Every other column of the contract stays required. The PP results and ITT results sheets print no benefit-or-harm label, so a legacy result loses nothing there.
The sheets gain no column. The cache stores the decision; the sheets render what they rendered before.
interval_status names a third state, "spans null"
The column read "ok" or "zero-event arm". It now reads "ok", "spans null" or "zero-event arm".
"spans null" means the bootstrap interval is estimable and contains the null. The number needed to treat has no interval there, because x -> -1/x is undefined across zero. Such a band read "ok" before, and the reason reached the reader only as an empty cell on a figure.
"ok" therefore says more than it did. It now means the interval is estimable AND strictly excludes the null. Code that tested interval_status == "ok" to find a band with an interval MUST test interval_status != "zero-event arm" instead.
"zero-event arm" wins where a band could take two states. An NA bound cannot be judged against the null, and the zero-event arm is why the bounds are NA.
The risk-difference bootstrap multiplies its replicates in batches
$risk_difference() now multiplies 50 bootstrap replicates against the arm matrices at once. It multiplied one replicate at a time before. Each product is now one level-3 BLAS call in place of one call per replicate, and the arithmetic is memory-bandwidth bound. Measured at 500 replicates on a national-registry panel, the batched form runs 3.1 times faster.
The numbers do not move. The estimator draws one multiplicity vector per replicate, in replicate order, by the same call it always made. So the random number stream is unchanged, and the same seed returns the same survival, risk difference, interval and count columns. A test pins every one of those columns to values measured on the old code, cell for cell. It pins the stored replicate matrix too.
The batch size is fixed at 50 and is not an argument. Sizes of 50, 100, 250 and 500 are within 1 percent of each other on speed. A reachable size would let a performance setting move a published confidence interval.
$s3_analyze() computes the absolute scale for every emulated trial
The risk difference was computed in the export path, behind the "forest" figure option risk_difference. A script that did not set the option drew every figure without the quantity. There was no error and no warning.
$s3_analyze() now computes it, for every ETT and with nothing to switch it off. Two estimand and weight combinations carry it. Per-protocol on analysis_weight_pp_trunc stores under rd_pp_trunc. Intention-to-treat on ipw_trunc stores under rd_itt. Per-protocol on the untruncated weight carries rates and the incidence rate ratio only, as before.
Each slot holds one summary row at the end of follow-up. The row carries rd, rd_lo, rd_hi, nnt, nnt_direction and interval_status.
The survival curve is stored beside the risk difference
rd_curve_pp_trunc and rd_curve_itt hold the whole band-by-band curve, with surv_comparator and surv_intervention next to the risk difference. The risk difference is S_comparator(t) - S_intervention(t), so those two columns were computed to produce it and then thrown away. The export path read the analysis panel off disk to rebuild them.
The curve is WIDE: one row per band, one column per arm. Measured on a 39-band curve, the stored row and curve together serialise to 2,335 bytes, which is 2.5 MB across 540 ETTs and two estimands. The n_boot by n_band replicate matrix is dropped, because the stored percentiles already summarise it. It measures 156,216 bytes per curve, so keeping it would add 169 MB to that same plan.
The bootstrap settings belong to s3, and a figure cannot change them
$s3_analyze() runs the bootstrap at 500 replicates with seed 1. Both are fixed there and recorded on every stored row and curve. A reader of plan$results_ett can reproduce an interval from the plan alone.
The "forest" figure options n_boot, seed and conf_level no longer reach the estimator. A manifest that sets one now warns and names where the value belongs. Remove them from the manifest. A figure that could lower the replicate count could lower the precision of a published interval.
The confidence level is a study property: study$implementation$conf_level
$s3_analyze() reads the risk-difference confidence level from spec$study$implementation$conf_level, and defaults to 0.95. A study that wants 90 percent intervals writes conf_level: 0.90 once. Every stored row, every stored curve and every printed header then carries 90 percent.
The level MUST be a single number strictly between 0 and 1. $s3_analyze() refuses anything else before it dispatches a single worker, and the message names the field.
A per-exhibit conf_level in a forest figure is ignored and warns. s3 computes the interval long before any figure exists, so one study has one level. A figure that could restate the level would print a label the numbers do not have.
risk_difference = TRUE is a display switch and computes nothing
The "forest" option still decides whether the figure carries the two extra columns. It no longer decides whether the quantity exists.
The forest figure reads plan$results_ett and opens no file. The survival figure still reads its analysis panel, because its numbers-at-risk table needs n_persons_at_risk, a distinct-person count that no survival curve carries.
The PP results and ITT results sheets are unchanged in layout. They read the same slot they always read. An export that ran $s3_analyze() now finds the four risk-difference columns populated, in whatever order the export steps ran.
Five accessors return every stored result as one flat table
TTEPlan gains $get_estimates(), $get_curves(), $get_baselines(), $get_attrition() and $get_subgroups(). Each takes no argument. Each returns everything the plan stores. The caller filters.
A reader needed three things to reach one number: the slot name, the estimand that slot stands for, and the column names inside it. The five methods carry all three as columns.
$get_estimates() returns one row per emulated trial, estimand and weighting. $get_curves() melts the wide stored curve into one row per arm and band.
estimand and weights are two columns, not one. estimand reads "pp" or "itt". weights reads "truncated" or "untruncated", and names the weighting choice inside per-protocol. Three combinations occur.
No consumer changed. The workbook, the figures and the CONSORT diagrams still read the result slots directly.
An accessor computes nothing and creates no row
An accessor MUST NOT compute a result. It MUST NOT apply a rule or a threshold. It MUST NOT read an analysis panel. It MUST NOT create a row that no slot backs.
A missing slot yields absent rows. The rule keeps staleness visible. A plan saved before a stage ran holds fewer slots than a plan saved after it. An accessor that recovered the gap would report a full table over a partial plan.
Three rows per emulated trial is an upper bound, not a promise. $get_estimates() gives a combination a row when the plan holds at least one of its three slots. A combination the plan holds nothing for gets no row.
$get_baselines() shows the same rule. The "raw" baseline panel needs a separate pre-imputation file, and that file is optional. The table then holds no "raw" rows, and no other panel appears under that name.
Numbers come back bare. irr_pvalue is a probability, not "<0.001". rd is a proportion, not a rate per 10,000. The consumer formats it.
Baseline cell values are the one exception. overall, comparator and intervention are display strings, because the producer stores them that way.
$s3_analyze() stores irr_estimable beside the incidence rate ratio
An arm with no event gives a ratio of exactly 0, which is finite. Printed bare it reads as a point estimate of no risk, known perfectly. It is neither.
.tte_irr_estimable() is now the one place the package answers that question. $s3_analyze() calls it and stores the answer as the irr_estimable column, as it stores nnt_direction beside the risk difference. The results sheet calls the same function, so the sheet and the stored column cannot disagree.
$get_estimates() READS that column. A result stored before the column existed reports NA, and the accessor does not apply the rule to fill the gap.
$get_attrition() returns the stored global rows only
$s1_generate_enrollments_and_ipw() stores one attrition row per trial and criterion, plus one global row per criterion. The global row carries the true overall count of distinct people. $get_attrition() returns the global rows.
A criterion with no global row gets no row. $get_attrition() never rebuilds one by summing the per-trial rows. That sum counts a person once per sequential trial she enters, so it over-counts n_persons and reports a wrong number with no signal.
An attrition file written before the global row existed therefore returns fewer criteria than it holds. The CONSORT renderer keeps the old fallback, where an inflated number beats no number at all, and .attrition_overall() is unchanged.
The table holds the eligibility cascade only. It holds no matching step, no analysis step and no per-step change columns. $s1_generate_enrollments_and_ipw() stores neither of those two steps. .build_cohort_flow() builds them and derives the change columns. Building a row is a renderer’s job, so .build_cohort_flow() stays the one place it happens.
The table carries no step kind either, because nothing stores one. The first stored criterion is the cohort start and every later one is an exclusion, so a consumer labels them from step_order.
A consumer that needs the full participant flow passes the plan to the CONSORT renderer, as it did before.
$get_curves() carries the numbers at risk beside survival
n_persons_at_risk is an unweighted count of distinct people, per arm per band. $s3_analyze() stores it and $get_curves() melts it, so a consumer can draw a risk table without opening an analysis file.
The count is READ, not derived. A risk table reports people. Survival is a weighted probability, so no head count follows from it.
A curve stored before that column existed gives NA. A consumer that draws a risk table MUST check for missing values and refuse to draw. A row of missing counts looks like a drawn risk table.
$get_subgroups() returns both p-values, and they never share a name
irr_pvalue is the stratum’s own p-value. It answers whether that stratum’s rate ratio is distinguishable from the null. em_pvalue is the interaction test. It answers whether the strata differ from each other.
The two are not interchangeable. A consumer that renders one where the other belongs reports a different finding, so the table gives them different names.
em_pvalue, ratio_of_irrs, ratio_lo and ratio_hi all come from the stored interaction test. That is the same result the Effect modification exhibit reads, so the exhibit and the accessor report one number rather than two.
$irr_by_subgroup() runs the interaction test a second time and attaches its own em_pvalue and ratio_of_irrs attributes to the stratified table. $get_subgroups() does not read those attributes. One of the two calls can fail while the other succeeds.
$get_subgroups() reads the union of two stored families
$s3_analyze() dispatches the stratified rate ratios and the interaction test as separate work items, in separate subprocesses. Either can fail alone.
$get_subgroups() returns a row for every subgroup variable and estimand that EITHER family stores. It keys neither family off the other. Four states occur.
- Both stored. Full rows.
- Stratified only. One row per stored level, with all four interaction columns
NA. - Interaction only. One row, with
subgroup_levelreading"all"and the four stratum columnsNA. - Neither stored. No rows, even when the specification names the variable.
A skipped stratified result reads as absent, so state 3 covers a failed stratified worker beside a stored interaction test.
State 3 used to return nothing. The Effect modification exhibit reported the interaction p-value there, and the accessor could not.
In state 3 no stored table names the strata, so the accessor names none. "all" is the level $irr_by_subgroup() gives its whole-cohort row, and it is the level the Effect modification exhibit prints in the same state.
State 4 is where the accessor and the exhibit differ, and the difference is deliberate. The exhibit reads the specification and prints one empty row for a variable it stored nothing for. The accessor reads stored results and returns no row.
subgroup_var is part of the $get_subgroups() key
One emulated trial MAY carry several subgroup variables, and each one has its own "all" row. The grain is therefore the emulated trial, the estimand, the weighting, the subgroup variable and the subgroup level.
A spec reload no longer overwrites a cached description
$reload_spec() refreshed plan$ett$description, then wrote the new label onto every cached result in plan$results_ett. That write is gone.
A description is input-derived, and plan$ett owns it. The accessors join the current label from there. A stale result carried a fresh label before, which hid the age of the result.
A study that needs the label a result was computed under MUST store it under its own immutable name.
$export_tables() reads the accessors, and the workbook does not move
Every sheet writer and every figure builder in the export path now calls $get_estimates(), $get_baselines(), $get_subgroups() or $get_attrition(). None reads a slot of plan$results_ett or plan$results_enrollment. A slot rename can no longer reach a cell.
The output is unchanged. A test builds two stored-result generations across four project shapes and runs one export for each. It compares every sheet cell by cell, and every image on content, inventory and pixel size, against a stored snapshot. All eight exports match.
$export() moved with it. The forest exhibit builds its risk-difference lookup from $get_estimates(), and the table1 exhibit composes its CSV from $get_baselines().
Three consumers still read a stored list, and each says why in its own comments. $results_summary() and $print_target_checklist() report on the CACHE, and they separate an absent slot from a skipped one, which an accessor reports the same way. The CONSORT diagram and the attrition sheet read plan$enrollment_counts for a second reason. .build_cohort_flow() needs the matching block and the per-trial rows, and $get_attrition() returns the global eligibility rows only.
A baseline panel is composed from $get_baselines(), not read whole
The Table 1, Table S1-Table SN and table1 CSV exhibits build their display panel from the accessor rows. Two rendering conventions are the renderer’s, and it restores both. The variable name prints once per block. The SMD column is the display string that .t1_fmt_smd() builds from the stored smd_numeric.
The arm column headers now come from the CURRENT specification, through .lookup_arm_labels(). Every other sheet already read the specification for those labels, so the workbook is consistent for the first time. A specification edited between $s3_analyze() and $export_tables() changes those two headers and no number. A specification that names no arms heads the two columns FALSE and TRUE, which are the two values of the treatment variable.
.ff_irr_ci() reads the stored estimability decision
The forest cell tested irr < 0.01 and blanked the cell itself. It now reads irr_estimable, which $s3_analyze() decides through .tte_irr_estimable() and stores beside the ratio. The plottability guard behind the forest panel reads the same column.
A ratio stored before that column existed carries NA. The one shared rule then answers, so a legacy result renders what it always did.
The >100 display cap is a SEPARATE decision and it stays in the formatter. .tte_irr_estimable() answers “may this ratio be reported at all”, and the cap answers “does this ratio fit on the axis”. A ratio of 150 is estimable, and it prints as >100. The two share a number at the lower edge and are not the same question, so .FOREST_IRR_PANEL_RANGE names the drawing window and says so.
.prepare_combine_data() no longer stops on one absent description
The helper read description off every stored result before it subset the list. One emulated trial whose copy was absent, or was not one string, stopped the whole call, including the trials the caller had asked for.
The description now comes from plan$ett, which holds one row per emulated trial and is an input. A trial the grid does not name falls back to its own identifier.
$get_attrition() returns every stored row, and $get_matching() is new
$get_attrition() returned the global rows only. It now returns every stored attrition row, per-trial and global, and it carries trial_id. That column is NA on a global row and the trial index on a per-trial row, so the caller filters on it.
The method still computes nothing. It does not sum the per-trial rows, it creates no global row for a criterion that has none, and it fills no value down. step_order is the criterion’s position in stored order, so every row of one criterion carries the same value.
Summing is a RENDERER’s decision and .attrition_overall() still makes it. That sum counts a person once per sequential trial she enters, so it over-counts n_persons. For a CONSORT diagram an inflated number beats no number. Returning a stored row is not summing it, so the accessor can supply the rows without making the decision.
$get_matching() is a sixth accessor. It returns the stored matching counts, one row per enrollment and trial. It is a separate method rather than four more columns on $get_attrition(). The matching table has one row per enrollment and trial, and the attrition table has one row per enrollment, trial and criterion. Joining them would repeat one matching count on every criterion row.
The participant flow reads the accessors
.write_attrition_sheet(), .format_enrollment_summary(), $print_target_checklist() and the CONSORT renderer read plan$enrollment_counts before. All four now read $get_attrition() and $get_matching().
No consumer reads plan$results_ett, plan$results_enrollment or plan$enrollment_counts any more. Three classes of reader remain, and each states its reason in its own comments. The producers write those lists. Three readers of the KEYS answer “did this stage run”. Two console reporters separate an absent slot from a skipped one.
The numbers do not move. The Attrition sheets and the CONSORT diagrams are unchanged, cell for cell and pixel for pixel.
$export_tables() works without a RegistryStudy
.write_provenance() called format(NA, "%Y-%m-%d %H:%M:%S") when a plan carried no registry_study_created_at. format.default() read the format string as its trim argument and stopped with invalid 'trim' argument, so the whole export failed. An absent timestamp now prints as an empty cell.
An accessor reports what is stored, including that nothing is
Four states stored a fact that the accessors could not report, so a cell that used to carry a number went blank. Each is a supported state, and each is fixed.
$get_baselines() carried the three enrollment counts on the rows of a stored panel. An enrollment that stored counts and NO panel now gets ONE row instead, with every panel column NA. .baseline_panel_is_stale() calls such a result CURRENT, so it reaches the sheets, and the Enrollments sheet and the CONSORT analysis step report its size again.
$get_estimates() gains irr_stored and irr_interval_stored. Every numeric column reports an absent slot and a stored NA the same way, as NA, and two consumers must separate the two. The PP results and ITT results sheets report the arm counts of a combination whose rate ratio failed. They report nothing for a combination that has no rate-ratio slot.
$get_estimates() returns ONE row for an emulated trial that stored a result and no estimate slot, with estimand and weights both NA. A stored summary is a stored slot, and the ETTs sheet reports its event count again.
$get_baselines() gains comparator_label and intervention_label, the two arm names the STORED panel heads its columns with. The Table 1 and Table S1-Table SN sheets read them from there. They no longer re-read the specification, which would head yesterday’s numbers with today’s labels.
No consumer that asks for a panel or an estimand sees either new row. Each one filters on a key column, and NA == "imputed" is NA, which selects nothing.
Absence is a stored SHAPE, never a missing value
$get_estimates() gains rates_stored and rd_stored. $get_baselines() gains smd_stored. $get_subgroups() gains strata_stored. Each reports whether the plan HOLDS a usable table, and none reports what is inside it.
A consumer that read a missing value as a missing table dropped a row that the raw-slot reader kept. Four consumers did:
- The
PP results,ITT resultsandWeight truncation (PP)sheets dropped an emulated trial whose rates table heldNAnumbers, and with it the trial identifiers and the rate ratio beside them. - The
Effect modificationsheet dropped a stored subgroup level whose per-protocol estimates wereNA. That discarded the intention-to-treat result for the same level. A real finding disappeared because a different estimand could not be computed. - The
Table 1andTable S1-Table SNsheets dropped theSMDcolumn of a panel whose every standardised mean difference wasNA. - The forest figure of
$export()dropped a stored risk-difference row whose values wereNA.
All four report the stored shape now. A row with missing numbers renders blank cells, which is what it always did.
rates_stored is TRUE when the stored table passes four checks. It is a data.table. It carries the three measurement columns. It carries its treatment_var attribute as a column. It holds exactly one row per arm. Those are the four checks the raw-slot reader made.
$results_summary() is the one diagnostic exception
$results_summary() reads plan$results_ett directly, and it is named as a diagnostic rather than as a consumer. A tool that reports ABSENCE cannot read through an interface that hides absence. The accessors report a missing slot and a skipped slot the same way, and they expose no skip envelope and no failure reason. This method prints exactly those three states: "NULL", "SKIP: <reason>" and "OK".
It reports on the cache and never on a number. A caller that wants the numbers calls $get_estimates().
.build_forest_df() returns irr_estimable in place of warn
The warn column recorded whether the Poisson fit warned. No renderer read it and no test asserted on it. irr_estimable takes its place, and the two formatters that print a ratio read that column.
The supplement workbook carries no forest plot
$export_tables() wrote three forest sheets: PP forest plot, ITT forest plot and ITT vs PP forest. Each sheet held a title, a treatment legend and one image. All three are gone, and so are their six sidecar files.
The PP results and ITT results sheets report every emulated trial. Each row carries the counts, the rates, the ratio, the risk difference, the interval and the number needed to treat. The images repeated a subset of those numbers.
$export() still draws a forest figure for a manuscript. That route is unchanged.
Every surviving sheet keeps every cell. The Love plot and the CONSORT sidecars keep their content and their pixel dimensions. The table of contents loses the three rows and renumbers.
$export_tables() names the trial its protocol sheet documents
The Target trial protocol sheet documents ONE emulated trial. featured_etts chose it, as a side effect of a figure argument. A list that chooses which trials a paper figure shows MUST NOT decide what a supplement sheet describes.
protocol_ett_id replaces that pick and does one thing. Pass the ETT id the sheet MUST document. An id the plan does not hold raises a warning and falls back.
The fallback is unchanged. Without protocol_ett_id, the sheet describes the first trial of the Table 1 enrollment, and otherwise the first trial in the grid.
$export_tables() no longer accepts featured_etts, forest_label_format, forest_desc_header or forest_role_headers. All four served the removed images. A call that passes one now fails with an “unused argument” error. $export() keeps its own label_format, desc_header and role_headers for the manuscript figure.
The CONSORT fallback counted every affected criterion twice
.attrition_overall() collapses the stored attrition table to one row per criterion. That table holds per-trial rows, and since the global-row change it also holds one global row per criterion. The two sets describe the same people.
The fallback summed both sets. A criterion with a global row and per-trial rows therefore contributed both, so the diagram counted it twice. The fallback now reads the per-trial rows and nothing else.
The fallback runs when at least one criterion has no global row. An attrition file written before the global row existed has that shape.
Measured on the test fixture, the starting cohort read 5,700 persons and 48,000 person-trials. It now reads 3,100 persons and 24,000 person-trials.
tables_consort_* and the Attrition_* sheets both read this function, so both move for an affected enrollment. An enrollment whose every criterion carries a global row is untouched.
The fallback number is still not a count of persons, and that is by design. It sums one unique-person count per trial, so it counts a person once per sequential trial she enters. The CONSORT box prints it under a “persons” heading, and the unit is really person-trials. For a legacy file an inflated number beats no number, but the heading overstates what the number is. This release records that, and does not repair it.
The number needed to treat is stored with its interval
$risk_difference() gains two columns, nnt_lo and nnt_hi. $s3_analyze() copies them onto the stored row, and $get_estimates() returns them.
One site computes them. .tte_nntb() maps a risk-difference interval onto the reciprocal scale, and .tte_rd_curve() calls it. No consumer inverts rd_lo and rd_hi again.
Before this release the accessor carried the point estimate alone. A figure that wanted the interval had to invert the bounds itself, which would have put a second estimator in the reader.
A spans-null band has no reciprocal interval, and both bounds say so
x -> -1/x is undefined across zero. An interval that contains the null therefore has no reciprocal interval, and nnt_lo and nnt_hi are NA on exactly those bands. interval_status reads "spans null" there, so the missing value has a stated reason.
The point estimate stays. It is a valid descriptive quantity.
A formatter that prints an interval MUST NOT print the point estimate alone there. .tte_nntb_cell() renders an empty cell, and .ff_rd_ci() renders not estimable for the matching risk-difference case. Both conventions are honest. Neither prints a finite-looking interval.
The risk-difference curve carries the numbers at risk
$risk_difference() gains n_persons_at_risk_comparator and n_persons_at_risk_intervention. Each is uniqueN() over the person identifier, per arm per band.
It is the same count $survival_curve() returns as n_persons_at_risk, taken on the same panel. It is neither the row count, which counts person-trials, nor sum(w), which is the weighted risk set and the denominator of the hazard.
No consumer opens an analysis file to render
The survival figure read one analysis file per estimand. It read it for the numbers-at-risk row alone, because the stored curve carried no head count.
$s3_analyze() now stores that count, so the figure reads $get_curves() and the read is gone. No renderer in $export_tables() or $export() opens an analysis file. s3 computes and s4 formats.
A plan whose stored curve predates the count gets an error that names the repair, which is to re-run $s3_analyze(). The figure MUST NOT draw a risk table of missing values.
One analysis-file read remains in $export_tables(), and it belongs to a PRODUCER. A baseline panel that an earlier release wrote is stale, and $export_tables() then calls $recompute_baselines(). That method calls .s3_enrollment_worker(), which is s3’s own worker. The worker opens the analysis file, computes a Table 1 panel and stores it. That is s3 running late, not s4 computing a result.
The two are different operations and this release separates them in the tests. One test pins that neither entry point reads a panel to render. A second test pins that a stale panel reaches .s3_enrollment_worker(), and that the worker is the only thing that opens a panel.
Call $recompute_baselines() yourself when you want the refresh to be a step you can see. The lazy path costs minutes, and whether it runs depends on what a cached plan happens to hold.
A figure may look different on two machines, and the numbers do not
ggplot2::ggsave() chooses the program that turns a plot into pixels. It uses ragg when ragg is installed, and cairo when it is not. swereg names ragg in neither Imports nor Suggests, so the choice follows the machine.
Two machines therefore MAY write PNG files that differ. Text weight is the visible part. Every number on the figure is the same, because the same data reaches the same renderer either way.
Install ragg when you need one appearance across a group of machines. Do that in the analysis project, not in swereg. swereg names no device, so a user who prefers cairo keeps cairo.
The tests follow the same rule. They pin the data that reaches each renderer, the file inventory an export writes, and the pixel size of every image. They compare no pixels. A pixel comparison reports which program drew the figure, which is not a property of the result.
swereg 26.8.19
The headline Table 1 carries a standardised mean difference
$export_tables() and the "table1" exhibit now write an SMD column on the main baseline panel. The supplementary panels already carried one. The headline panel did not. A reader of the primary table had to open a second sheet to judge covariate balance.
The main panel keeps the rest of its layout. It emits no Missing row. Its percentages divide by the non-missing denominator, so the levels of a variable sum to 100. Only the SMD column is new.
.baseline_panel_is_stale() now reads every panel a cached result holds, not the first one it finds. A cache whose main panel lacks smd_numeric is stale, even when the supplementary panel carries one. That state is real: an earlier release refreshed the supplementary panels and left the main panel alone. The old predicate stopped at table1_ipw_trunc and reported the result as current. $export_tables() then wrote the old table, with no error and no warning.
A cached plan needs no manual step. $export_tables() marks the panel stale and calls $recompute_baselines(), which reads the analysis files from output_dir.
Absence is still not staleness. A panel the worker never produced is NULL, and the check skips it. table1_raw is NULL when no raw file sits on disk. table1_ipw_trunc_main is NULL when the enrollment has no ipw_trunc column.
The exported CSV carries the formatted SMD string and not smd_numeric. smd_numeric stays a programmatic contract for the Love plot and for balance checks, so .export_table() strips it before it writes the file.
A zero-event arm gets no confidence interval
$risk_difference() now returns NA for rd_lo and rd_hi at any horizon where either arm has no positive-weight event.
An ordinary empirical bootstrap cannot produce an event the sample does not hold. Every replicate draws from the same event-free set, so every replicate gives that arm a failure risk of exactly zero. The interval then carries only the other arm’s sampling variation, and treats this arm’s risk as known with certainty. That is anti-conservative. More replicates do not repair it, because the degeneracy is in the resampling scheme and not in the sample size.
One production figure showed the defect. A row with zero weighted events in the intervention arm left its incidence rate ratio blank, correctly. It still printed a risk difference with a full interval, and a number needed to treat beside it.
The point estimate stays. It remains a valid descriptive quantity, and a new interval_status column says why nothing accompanies it. That column reads "ok" where the interval is estimable and "zero-event arm" where it is not. A reader can therefore separate an interval that spans the null from an interval that does not exist.
The condition is evaluated per horizon and per arm, on the events up to and including that band. An arm can have no event by week 52 and several by week 156. The week-156 interval is then estimable, and only the week-52 one is suppressed.
The number needed to treat suppresses itself from there. .tte_nntb() already returns NA when the interval does not strictly exclude the null, so a missing interval renders an empty cell.
The suppression reads the WEIGHTS. An event whose weight is zero contributes nothing to either bootstrap sum, so it is not an event this estimator can resample. The unweighted n_persons_with_event_* columns still report that person, and the two columns answer different questions.
The number needed to treat carries its interval
.tte_nntb_cell() gains nntb_lo and nntb_hi. Supply both and the cell reads NNTB 2,000 (1,250 to 5,000). Supply neither and it renders the point estimate alone, as before.
The bounds already existed. .tte_nntb() has always returned them, so nothing new is computed here.
Both bounds take the point estimate’s thousands separator and its 0 decimal places, because a fractional number needed to treat is not a quantity. The separator between them is to, the one the risk-difference column already uses.
The printed bounds ascend on both signs, and the two branches reach that differently. .tte_nntb() guarantees nntb_lo < nntb_hi, so the benefit branch prints the bounds in the order it holds them. The harm branch negates each bound, which reverses the order, so it prints the negated high bound first. The negation is explicit and never abs().
A row whose bounds are missing renders EMPTY, even when the point estimate is finite. A point estimate printed without its interval invites a reader to treat it as precise, and a zero-event arm is exactly where it is not.
The labels stay NNTB and NNTH in full. They are the Cochrane and GRADE terms, and the abbreviated forms are not recognised notation.
.forest_rd_map() in R/forest_plot.R still calls the one-argument form, so the figure column shows the point estimate alone. Passing the bounds there is a separate change. The cell grows from about 10 characters to about 27, so the column’s relative width MUST be revisited at the same time.
The survival figure carries numbers at risk
.render_survival_curve() now composes two panels: the curve on top and a numbers-at-risk table beneath it. $survival_curve(save_path = ...) and $export() therefore write a figure a reader can interpret without going back to the data.
The table is populated from n_persons_at_risk, the count of DISTINCT PEOPLE. It is never populated from at_risk, which is the weighted risk set sum(w) and is the hazard denominator. The two differ on every real panel, because the weights are not 1 and because one woman holds several sequential trials. A risk table reports people, so the panel title says “Numbers at risk (persons)”.
Both panels are given the same x breaks and the same x limits, from one scale built once. A risk table whose columns do not sit under the curve’s ticks is worse than no risk table, so that sharing is checked directly: a test compares the two built panels’ x range and x breaks.
The x axis is now drawn once, under the table at the foot of the composition, and the curve panel’s own x axis is blank.
A panel holds more bands than the table has room for, so the labelled times are thinned. The rule counts backwards from the last band in steps of one fixed stride. Every gap between adjacent labelled times is therefore the same width, and the last band is always labelled. Counting forwards and then adding the last band instead leaves one short final gap, and six-digit counts printed in that gap overlap. On a 156-week panel the final gap was 12 weeks against a 20-week stride, and two counts rendered as one number. A test pins the property: every gap MUST be equal, and the last band MUST be kept.
The returned object is a patchwork. It still inherits ggplot, and the curve is the composition’s own plot, so ggplot2::layer_data() and ggplot2::get_labs() applied to the return value describe the curve exactly as before. patchwork was already a dependency; nothing new was added.
Whether the table is legible at publication size is a human judgement and has no test.
Survival curves can plot cumulative failure
$survival_curve() gains a scale argument. "survival" is the default and is unchanged: it plots surv, starting at 100%. "cumulative_failure" plots 1 - surv, starting at 0, which is readable for a rare outcome where the survival curve sits pinned against the top of the panel.
Deaths are censored, not modelled as a competing risk, so 1 - surv is cause-specific failure under independent censoring and NOT a competing-risk cumulative incidence function. The y label says “Weighted cause-specific cumulative failure” for that reason, and a test pins the wording.
The plot is now built by a pure internal renderer, .render_survival_curve(), which returns a ggplot and writes nothing. $survival_curve() delegates to it. Both scales therefore share one code path and cannot drift apart. The origin row is transformed with the rest of the curve, so a cumulative-failure curve starts at 0 rather than at 1.
$export() now writes the survival figure on the cumulative-failure scale. Nothing else about the figure changed.
Table 1 keeps the unrounded standardised difference
.swereg_table1() formatted the standardised mean difference to three decimals and discarded the double it formatted. Anything downstream that needed the number had to parse the string back, which silently rounds: an SMD of 0.8911 returned as 0.891.
The double is now kept as a real column, smd_numeric, alongside the SMD display string. It is a column and not a row-parallel attribute on purpose. Row subsetting, reordering, rbindlist() and a qs2 round trip are all free to drop an attribute; none of them can separate a column from its own row.
smd_numeric is a programmatic contract, not a display column, so it is removed before a panel is written to a worksheet or a CSV. No rendered table gains a column.
$export_tables() refreshes a baseline cache that predates smd_numeric
The staleness test asked only whether a cached panel carried the swereg_table1 class. A panel written between the swereg_table1 change and this one carries that class and has no smd_numeric, so it was declared current and never recomputed. A panel is now stale when it lacks either.
New sheet: Love plot
$export_tables() writes a “Love plot” sheet for the Table 1 enrollment, showing the absolute standardised difference for every covariate twice: unweighted, and under the IPW-truncated analysis weights. The conventional 0.1 balance threshold is drawn as a dashed reference line. PNG (300 dpi) and vector PDF sidecars are written next to the workbook, as the forest plot sheets do.
The weighted series is table1_ipw_trunc, not table1_ipw. The truncated weights are the analysis weights; the untruncated panel is a robustness variant and is not plotted.
The person id is always present, and numbers at risk count persons
TTEDesign$new() defaulted person_id_var to NULL. Nothing in the pipeline relied on that default: create_skeleton() names the person identifier id, and TTEPlan already passes "id" whenever an argset does not override it. The default is now "id" too, so a TTEDesign built by hand matches one the plan builds, and a person id is always available.
Two guards existed only to cover the NULL case, one in $summary() and one in $rates(). Both are removed. $rates() still emits n_persons; it just stops being conditional and can no longer report NA for a design that omitted the argument.
$survival_curve() gains a column, n_persons_at_risk. It is an unweighted count of distinct people per arm-band, taken over design$person_id_var.
Three different numbers live in one arm-band cell of a sequential target trial emulation, and only the third is what a risk table under a survival panel reports:
- rows, which are person-trials, because one person is enrolled into many sequential trials
-
at_risk, the weighted risk setsum(w), which is the hazard denominator -
n_persons_at_risk, the head count
On a large national-registry panel the gap is real: person-trials outnumber persons, roughly one trial per person on average, with a minority of women holding several. Reporting rows where persons are meant overstates the sample.
at_risk is unchanged and still drives the hazard, so no estimate moves. The existing column set is unchanged; this adds one.
New method: $risk_difference()
TTEEnrollment gains $risk_difference(weight_col, n_boot, seed, conf_level). It returns, at each band, the signed cause-specific risk difference with a percentile bootstrap interval, alongside the two arm survivals it was formed from and per-arm event counts.
The sign convention is fixed and is not a display choice:
RD(t) = Risk_intervention(t) - Risk_comparator(t)
= [1 - S_intervention(t)] - [1 - S_comparator(t)]
= S_comparator(t) - S_intervention(t)
The stored rd is signed. A protective intervention gives a negative risk difference, and that minus sign is the result. abs() appears nowhere in the arithmetic. An absolute risk difference has a correct-looking magnitude at every band, so no assertion on the point estimate alone can see a stray one. Two independent tests can, and both ship with the method: the mirror test (relabel the arms, require the sign to flip), and the person-trial/person layout equivalence test, whose bootstrap replicates straddle zero even where the point estimate does not, so abs() breaks 83 of its 600 bit-identical replicate comparisons. Pinning the convention in two unrelated places is stronger than pinning it in one.
The two survivals are the ones $survival_curve() already builds, from the same weighted discrete-time hazard, so the figure and the risk difference are the same numbers. Deaths are censored rather than modelled as a competing risk, so this is a cause-specific risk difference under independent censoring.
The bootstrap resamples people
A woman contributes several sequential trials. Those trials share her baseline covariates and can carry the same outcome event, so they are not exchangeable: the resampling unit is the person, and a drawn person brings all of her rows. Resampling person-trials, or rows, would treat one woman’s repeated entries as independent observations.
One multiplicity vector is drawn per replicate and applied to both arms. Persons cross arms: a woman can be a comparator in an early trial and an initiator in a later one, and on a large national-registry panel a few percent of women appear in both. Drawing a separate resample per arm leaves the point estimate unbiased and biases the interval, because it discards the covariance between the two arms’ survival estimates. No point estimate can show that, so the shared vector is pinned by a test that reads back the vector each arm was actually multiplied by.
A replicate that draws no person for an arm, or that empties a band, yields NA for that band and onwards; the percentile step drops those rather than erroring or substituting a value. The replicate matrix the interval was read off is attached as the rd_boot attribute.
Event counts are distinct people
n_persons_with_event_comparator and n_persons_with_event_intervention count distinct PEOPLE who had the outcome at or before that band, in that arm. They are deliberately neither row counts nor person-trial counts. One woman can carry the event in two of her sequential trials; she is one person who had the outcome, and the columns are named so that cannot be misread.
$summary() and $rates() report the event ROW count instead, which equals the number of event-bearing person-trials, and on real analysis panels the two numbers differ.
Performance
The weighted hazard is sum(w * event) / sum(w) over the rows at risk, and both sums decompose additively over persons. The panel is therefore aggregated once into two dense matrices per arm, and a replicate is a single matrix product against the multiplicity vector rather than a second pass over the panel.
At the shape of a large national-registry panel (person-trials outnumbering persons, 13 bands, a few million rows) a replicate costs about 0.05 s, so 500 of them cost about half a minute after the one-off aggregation. The point estimate is bit-identical to $survival_curve() on the same data, which is the property that makes the aggregation an optimisation rather than a second estimator.
The matrix row is the person-trial rather than the person, because the bootstrap index is taken over the person-trial table. That is a representation of the person-indexed sum, not an approximation of it: a person’s multiplicity is carried by every trial she owns, so the two forms collapse onto each other. A test applies one person multiplicity vector to both forms across 300 draws, degenerate draws included, and requires the risk differences to be bit-identical.
No new dependency: these are base dense matrices, 46 MB each at that shape.
The method computes; nothing writes it to a workbook or a figure yet.
Number needed to treat for benefit
.tte_nntb() inverts the signed cause-specific risk difference:
nntb = -1 / rd
nntb_lo = -1 / rd_lo
nntb_hi = -1 / rd_hi
The minus sign is load-bearing. $risk_difference() returns Risk_intervention - Risk_comparator, so a protective intervention gives a negative risk difference, and negating the reciprocal makes a benefit read as a positive number of women. The value stays signed: a harmful intervention returns a negative number and keeps it, and abs() appears nowhere in the arithmetic. It is named nntb and never “NNT”, because a reader who meets a column headed “NNT” assumes the number is positive and means benefit.
The interval must strictly exclude the null. The map x -> -1/x is monotone increasing on each side of zero and undefined across it, so an interval that contains zero has no reciprocal interval to report. A bound of exactly zero touches the null and is therefore not exclusion of it: all three values are NA there, on both sides. Loosening either comparison to >= or <= is a one-character change that reports a finite number needed to treat for an interval compatible with no effect.
That NA means undefined, not absent, and it does make the displayed value depend on the interval: a band whose interval crosses zero shows nothing. It is a property of the reciprocal transform, not a decision to hide a non-significant result.
Deaths are censored rather than modelled as a competing risk, so this inverts a cause-specific risk difference under independent censoring.
.tte_nntb_cell() renders one cell, and the SIGN chooses the label. A positive value renders NNTB 250, the number needed to treat for benefit. A negative value renders NNTH 400, the number needed to harm. Those are opposite clinical statements, and the label is the only thing that separates them.
The magnitude comes from negating the stored value, never from abs(). The harm branch negates explicitly, so a reader of the source sees which branch they are in. The cell carries 0 decimal places and a thousands separator, because a fractional number of people is not a quantity.
Every row gets a cell. The function took an outcome_role argument and rendered a number for the primary outcome only. That guard is gone, so a secondary outcome now shows its own number needed to treat.
The forest plot shows the risk difference
.render_combined_forest_plot() gains an optional rd_lookup, keyed by ett_id. When it is supplied, two text columns join the figure. The first is the signed cause-specific risk difference per 10,000 people, with its interval. The second is the number needed to treat it inverts to.
The value is signed and stays signed. A protective intervention reads -4.88 per 10,000 and a harmful one reads +4.88; the sign is written explicitly on the point estimate and on both bounds, so a harm can never be read as an unsigned magnitude. abs() appears nowhere in the formatting.
A row the lookup does not carry renders an empty cell, not "NA" and not a zero. An empty cell says nothing was computed; either of those says something was.
The two columns are left out of the layout entirely when nothing populated them, rather than reserving width for a quantity nobody computed. An existing forest figure is therefore unchanged in layout.
The risk-difference header states the level the interval was computed at
conf_level is configurable on a "forest" exhibit spec, so a hard-coded 95% CI header would put a 90 percent interval under a 95 percent label. The number would be right and only the label would lie, which is the version of this defect that survives into a manuscript: a wrong number gets questioned, a mislabelled one gets believed.
The header is now built from the level, so conf_level = 0.9 heads the column 90% CI. An integer percentage prints with no decimal point and a non-integer one keeps only the digits it needs, so 0.975 heads it 97.5% CI.
One value, one source, and it is checked rather than trusted. .forest_rd_row() copies the level off the curve’s own conf_level attribute, set by the computation that produced the bounds, so the level travels with the numbers it belongs to. .render_combined_forest_plot() errors if the level it was told to print disagrees with the level the lookup carries, or if one lookup mixes two levels. A figure cannot be rendered with a header that contradicts its own interval.
The neighbouring IRR header keeps a fixed 95% CI, and correctly so. $irr() accepts no confidence level, and .fit_irr() computes its bounds with a hard-coded 1.96 multiplier. So 95 percent is the only level it can be.
The two event counts say which is which
The per-arm columns beside the risk difference are sum(event * weight) over event ROWS. One woman who carries the outcome in two of her sequential trials is counted twice, and each time by her weight. The risk difference counts distinct PEOPLE, unweighted. Two figures that read as the same quantity and are not is worse than one, so the arm headers say “weighted events / PY”.
The distinct-person counts are reported on the PP results and ITT results sheets, under headers that name persons. See “The results sheets carry the risk difference and the person counts” below.
$export() computes the risk difference for a forest, on request
The risk difference is not in the cached per-ETT results, so it cannot be read back the way the rates and the IRR are. A "forest" exhibit spec takes risk_difference = TRUE, and the export path then loads each featured ETT’s analysis panel and computes it.
That costs minutes per ETT, which is why it is opt-in and why n_boot (default 500), seed (default 1) and conf_level (default 0.95) are exposed on the spec: run a smoke pass at a handful of replicates before spending the full one. Without risk_difference = TRUE nothing is loaded and nothing is computed.
The row takes the LAST band of the curve, the risk difference at the end of follow-up. The person counts are already cumulative through that band.
A survival-figure ylim must declare its own scale
$export() draws the survival figure on the cumulative-failure scale. It was still passing spec$ylim through untouched, so a survival-scale window such as c(0.95, 1) would clip the entire failure-scale curve out of view through coord_cartesian(): a blank panel, with no error and no warning. No spec in this package sets ylim today, so nothing was broken in practice, but the documentation advises setting an explicit range for a publication figure, which is exactly when it would bite.
ylim now requires a companion ylim_scale, either "survival" or "cumulative_failure". A survival-scale window is translated onto the plotted scale, so c(0.95, 1) becomes c(0, 0.05) and shows the same band of the figure it always did. An undeclared window is an error, not a guess.
The scale is declared rather than assumed because neither pure convention is safe on its own: the mirror mistake, a failure-scale window read as a survival-scale one, blanks the panel just as quietly.
New sheet: Target trial protocol
$export_tables() writes a “Target trial protocol” sheet in the Dickerman Table S1 layout: Protocol component, Target trial specification, Target trial emulation, over the seven components (eligibility criteria, treatment strategies, assignment procedure, outcome, follow-up period, causal contrast, analysis plan).
The two content columns have different provenance, and that is the point. The specification column is the study team’s own protocol prose, authored in the spec under a new optional target_trial: key, appended to the clinical fields the spec already carries (criterion names, arm labels, outcome names, follow-up labels). The emulation column is rendered from the nested implementation: blocks the spec already carries. It is never read from YAML prose, and there is deliberately no emulation: key for it to read: a hand-written emulation column can drift away from the pipeline it claims to describe, and a rendered one cannot.
A spec materialises many ETTs, and one protocol table cannot stand for all of them. The sheet names in its title row the single ETT it documents: the enrollment, the outcome and the follow-up horizon. It documents the first featured ETT when featured_etts is supplied, otherwise an ETT of the Table 1 enrollment.
target_trial: is optional. A spec without it still renders the sheet, with the derived clinical text alone in the specification column. Two components, causal contrast and analysis plan, have no clinical section anywhere in a spec, so for those the target_trial: entry is the whole cell.
tteplan_read_spec() already tolerated unknown top-level sections, so no existing spec needs changing to keep parsing.
The eligibility row renders global inclusion criterion objects as well as global exclusion criteria. No spec written so far has one: inclusion_criteria holds only the isoyears pair, so that loop renders an empty set today. It is there so a spec that later adds one is not silently dropped from the table.
The forest plot reads left to right, and states its horizon
.render_combined_forest_plot() composes its text columns in one fixed order:
- the row description
- intervention weighted events / PY
- comparator weighted events / PY
- risk difference
- number needed to treat
- IRR
- the forest panel
A reader meets what each arm contributed, then the absolute difference between them. The number needed to treat says how many people that difference is. The ratio follows, and then the panel that draws the ratio.
A test pins the composed ORDER, not merely that the columns exist. Two adjacent columns swapped still draws, and every label is still on the figure.
The “People with event” column is gone from the figure. Those counts now go to the PP results and ITT results sheets, where a reader can sort and copy them. See “The results sheets carry the risk difference and the person counts” below.
The number-needed-to-treat column is composed only when a risk difference populated it, and a row whose interval spans the null renders an empty cell.
Three headers now name a time, and the wording separates a PERIOD measure from a POINT measure:
IRR over 156 wks Risk difference per 10,000 Number needed to treat
(95% CI) at 156 wks (95% CI) at 156 wks
The horizon is derived from the rows, never written as a literal. .forest_horizon() reads follow_up off the forest rows, and the rows MUST carry one value. Rows that mix horizons raise an error, because one header cannot state two. This is the contract .forest_rd_conf_level() already keeps for the confidence level. A caller that puts two follow-up horizons on one forest MUST now split it into two figures.
The two weighted events / PY headers take no time reference. PY already names the exposure measure, and five repetitions of the horizon would be noise.
.render_itt_vs_pp_forest() is a different renderer and is unchanged.
The results sheets carry the risk difference and the person counts
PP results and ITT results gain four columns after the measurement block:
| Column | What it is |
|---|---|
Persons with event (int) |
Distinct people in the intervention arm who had the outcome |
Persons with event (cmp) |
Distinct people in the comparator arm who had the outcome |
Risk difference per 10,000 |
The signed risk difference at the horizon |
Risk difference 95% CI |
Its interval, at the level the bounds were computed at |
The counts are unweighted head counts of people. Events (int) and Events (cmp) beside them are weighted sums over event ROWS. The two differ on every real row: the weights are not 1, and one woman holds several sequential trials.
The risk difference keeps its sign, and Excel prints a + on a positive value. The interval header states the level the bounds carry, so a 90 percent interval heads its column Risk difference 90% CI. Two levels under one header raise an error.
The four columns appear only when a risk difference was computed. Computing one costs minutes per ETT, so most exports have none, and four empty columns would claim a quantity nobody computed. A sheet with no risk difference keeps its 14 columns unchanged.
$export() caches each computed risk-difference row onto plan$results_ett[[ett_id]], under rd_pp_trunc or rd_itt. The sheets read that cache. So a workbook carries these four columns after a "forest" exhibit ran with risk_difference = TRUE on the same plan object, and not otherwise.
swereg 26.8.11
add_rx() no longer aborts on an ISO year outside the converter’s range
cstime supports ISO years of roughly 1900 to 2200. A supplied start_isoyearweek or stop_isoyearweek outside that range, such as "0001-01" or "9999-01", made the calendar check return NA rather than TRUE or FALSE. The NA propagated into the row-drop test and the call failed with missing value where TRUE/FALSE needed.
An unparseable year is malformed input. It is now dropped with the same warning as "2019-99" and "2019-53", and the surrounding valid rows are kept. One bad year no longer destroys the batch it arrived in.
swereg 26.8.10
add_rx() rejects a week 53 the ISO year does not have
Whether an ISO year has a week 53 depends on the year: 2020 has one, 2019 does not. The 26.8.9 well-formedness check was syntactic, so it accepted "2019-53", which then silently marked the 2019 annual row.
The check is now calendar-aware. It reads the last week of an ISO year off the package’s own converter, using the fact that 28 December always falls in the final ISO week of its own ISO year, so the check cannot disagree with the conversion it guards. "2020-53" remains valid.
add_rx() no longer reads fddd when fddd defines nothing
round(fddd) was evaluated before the branch that uses it, so a caller who supplied stop_isoyearweek or stop_date — meaning fddd defines no endpoint — still needed fddd to be numeric. A factor fddd errored with 'round' not meaningful for factors even though the value was never used. fddd is now read only when the stop endpoint is actually resolved from it.
Note for callers upgrading from 26.8.8 or earlier
Retiring the rule that preserved caller-supplied ISO week columns (26.8.9) removes a capability, and callers relying on it should know what it was.
Lost: supplied weekly endpoints outside the skeleton’s weeks used to express “match only if these exact skeleton rows exist”. A supplied start_isoyearweek of "2019-51", on a skeleton whose weekly period starts in 2020, matched nothing and left every row FALSE. It now remaps onto the "2019-**" annual row and marks it.
Workaround: filter or clip such rows before calling add_rx(). Drop the prescriptions whose endpoints fall outside the skeleton’s weekly period, or clip their endpoints to that period, and the result is what the old behaviour gave. There is no argument that restores it, and this is intentional: the remap is the same rule every derived endpoint follows.
swereg 26.8.9
add_rx() resolves, validates and remaps every interval by one rule
add_rx() accepts four optional columns: start_date, stop_date, start_isoyearweek and stop_isoyearweek. Versions 26.8.7 and 26.8.8 branched on which of them the caller supplied, so the sixteen combinations each needed their own reasoning and several were never specified. Validation could be skipped by supplying the right column, and a column that defined nothing could change the answer.
There is now one path. Each endpoint is resolved once, from its own provenance:
- start: supplied
start_isoyearweek, else the ISO week of the suppliedstart_date, else the ISO week ofedatum. - stop: supplied
stop_isoyearweek, else the ISO week of the suppliedstop_date, else the ISO week ofedatum + round(fddd) - 1.
The resolved pair is then validated on a single expression that every combination reaches, and only then remapped onto the annual rows. Three behaviours change as a result.
A column that defines no endpoint can no longer change the result. The duration filter now applies if and only if the stop endpoint is actually resolved from fddd. Previously, adding a stop_date column to a prescription whose interval was already fully given as ISO weeks flipped the output from no coverage to two weeks.
An invalid interval is dropped whatever the caller supplied. A row is dropped, with one warning naming the count, when either endpoint is missing, when either endpoint is not a well-formed ISO week, when the start week is later than the stop week, or when both endpoints came from dates and the start date is later than the stop date. The last of these catches an interval inverted by days but contained in one ISO week, which compares equal as week strings.
A malformed ISO week is rejected. A supplied start_isoyearweek of "2019-99" used to be injected into the interval ranking as a synthetic boundary and silently marked ten skeleton rows. It is now dropped with a warning. Well-formed means "YYYY-WW" with week 01 to 53, or the annual "YYYY-**" form.
An endpoint that is well formed but outside the skeleton’s weeks is kept. "2020-53" is a real week that a skeleton ending in "2020-52" does not carry, and the interval still covers every week before it. The same applies to a derived interval running past date_max.
add_rx() remaps caller-supplied ISO endpoints too (behaviour change)
Previously a caller-supplied start_isoyearweek or stop_isoyearweek was preserved exactly and never remapped onto the annual rows. A supplied endpoint that falls before the weekly period now marks the annual row of its ISO year, where it previously matched nothing.
On a skeleton whose weekly period starts in 2020, a supplied start_isoyearweek of "2019-51" now marks the "2019-**" row. That is the same rule every derived endpoint follows, and it is what repair 12 in 26.8.7 exists to express: a prescription covering week 51 of 2019 is covered by the 2019 annual row. A caller who wants the annual row can still write "2019-**" directly, and that continues to work.
Corrected warning after the ISO week conversion
The post-conversion filter counted tagged matches, so one prescription matching two requested codes reported 2 prescription rows dropped. It now counts source rows. Its text also named only start_isoyearweek while a missing stop endpoint fired it just as readily, and claimed a value could be “unknown to the skeleton”, which it never could.
swereg 26.8.8
add_rx() no longer writes into the prescription table (behaviour change)
add_rx() used to write start_date, stop_date, start_isoyearweek and stop_isoyearweek back into the caller’s lmed by reference, and to skip recomputing any of them on a later call if the column was already present. It now computes all four on a local working copy. lmed is read, never written.
This is a behaviour change, not only a bug fix. Any caller that relied on the helper columns appearing on its own lmed after the call must compute them itself. Nothing inside swereg did.
The write-back became unsafe in 26.8.7, because the ISO week columns now depend on the skeleton via the annual remap. Three consequences, all fixed here:
-
Reusing one
lmedacross two skeletons. The second call reused the first skeleton’s remap. A 2019 prescription that should have marked five weekly rows on a skeleton whose weekly spine covers 2019 instead marked one annual row and no weekly rows. -
Schema depending on unrelated bad rows. With
fddd = c(0, 7)the caller’s table gained one helper column; withfddd = c(1, 7)it gained four. Adding one invalid row changed the schema seen by every valid row. -
A cached
stop_datebypassing the duration policy. After one call wrotestop_dateback, a later call treated that column as caller-supplied and skipped the duration filter entirely, even iffdddhad changed to0.
add_rx() validates the coverage interval as dates, before the annual remap
The annual remap collapses every pre-weekly date of one ISO year onto a single "YYYY-**" string. An inverted interval whose endpoints share an ISO year therefore came out of the remap as an equal pair and read as one week of valid coverage. With edatum = 2018-05-10 and a caller-supplied stop_date = 2018-05-01, 26.8.7 marked the 2018 annual row TRUE and warned about nothing; 26.8.6 dropped the row.
add_rx() now checks the interval as dates, before the ISO week conversion, whenever both ISO week columns are derived. A row with a missing start_date or stop_date, or with stop_date before start_date, is dropped with a warning naming the count. The post-conversion filter stays as a backstop for the remaining case, a caller-supplied ISO week column, and its warning text now describes that case instead of blaming fddd.
Which drop applied on which path depended on the caller’s column set. That turned out to be the wrong shape and was replaced in 26.8.9, which validates every interval on one rule; read that entry rather than this one for the current behaviour.
Note on the annual/weekly string ordering
The 26.8.7 notes justified the ordering of "YYYY-**" against "YYYY-WW" from ASCII byte values. That reasoning was wrong: R’s <, min() and sort() use locale collation, not raw bytes. The conclusion is unchanged and was checked empirically — annual strings sort below the weekly strings of the same year under both C and en_US.UTF-8.
swereg 26.8.7
add_rx() reaches the annual skeleton rows
create_skeleton() builds two spines: an annual one ("<year>-**", is_isoyear == TRUE) covering every ISO year before the weekly period, and the weekly one covering the study period. The add_diagnoses() / add_operations() / add_quality_registry() family already remapped events that fall before the weekly spine onto the annual rows. add_rx() did not, so a prescription whose coverage started before the weekly period matched no skeleton row at all and was silently lost.
add_rx() now applies the same remap, to both ends of the coverage interval:
- A prescription entirely before the weekly period sets the annual rows of the ISO years it spans.
- A prescription that starts before the weekly period and ends inside it sets the annual rows of the pre-weekly portion and the weekly rows it covers. Weekly resolution is not lost.
- A prescription entirely inside the weekly period is unchanged.
Annual strings sort below every weekly string (* is 0x2A, 0 is 0x30), so the integer ranking used by foverlaps() keeps a boundary-spanning interval contiguous.
If the skeleton has no weekly rows at all, the remap is skipped rather than taking min() of an empty set.
add_rx() treatment periods are one day shorter, and non-positive durations are dropped
Two fixes to the interval derived from edatum and fddd:
-
Inclusive end.
foverlaps(type = "any")matches inclusively at both endpoints, so the oldstop_date <- edatum + round(fddd)coveredround(fddd) + 1days. It is nowedatum + round(fddd) - 1. A 7-day prescription starting on a Monday no longer reaches into the following week. -
Non-positive and non-finite durations. Rows with
round(fddd)missing, non-finite or<= 0are dropped before the ISO week conversion, with one warning naming the number of rows dropped. Filtering afterwards could not express this: once collapsed to weeks,fddd = 0andfddd = -1both look like a valid single-week interval and survived the existing inverted-interval filter. Negative durations occur in the register.
Both changes apply only when add_rx() derives the interval itself. A caller who supplies stop_date, start_isoyearweek or stop_isoyearweek keeps full control: the duration filter does not run on those rows, and no remap is applied to a supplied ISO week column. Other row drops are still possible — see 26.8.8, which adds date-level validation of the interval and describes exactly which drops apply on which path.
Callers should expect fewer TRUE weeks per prescription and more TRUE annual rows than before.
swereg 26.8.6
Study-specific code moved out of swereg
The study-specific functions are gone from swereg. They now live in a separate downstream package, which both consumer repositories already call.
Removed:
- The two study
add_lmed()entry points, their internal helpers, and the two files that held them. - The study approach workbook under
inst/. The downstream package now ships it.
Callers of either function MUST switch to the downstream package. swereg had no internal call sites, so nothing else in the package changes behaviour.
glue and readxl leave Imports. The two removed files were their only users.
swereg 26.8.5
qs2_read() reads standard qs2 format only
qs2_read() now calls qs2::qs_read() directly. It previously tried qs2::qd_read() (qdata format) first and fell back to the standard reader when the error message matched "qs2 format". The qdata attempt is gone.
Two consequences, both visible to callers:
-
Files in qdata format are no longer readable. A file written with
qs2::qd_save()now raisesqdata format detected, use qs2::qd_read. swereg has never written qdata files itself, so this affects only a caller who produced one by hand and read it back throughqs2_read(). Read such a file withqs2::qd_read()instead. -
Error messages change for every failing input, not only qdata ones. The removed
tryCatch()wrapper meant that a corrupt or missing file surfaced the error raised byqs2::qd_read(). The error now comes fromqs2::qs_read(). A corrupt file reportsUnknown file format detected; a missing file reportsFailed to open for reading. Code that matches on these strings needs updating.
The check_version() hook is unchanged: qs2_read() still calls obj$check_version() when the object read is an environment that has one.
swereg 26.8.4
qs2_write_atomic() now delegates to batchit
The atomic-write engine – temp file in the destination directory, then file.rename() into place – was implemented twice, once here and once in batchit. Two copies of the same guarantee drift: one gets a fix, the other does not, and they stop agreeing about what “atomic” means.
swereg::qs2_write_atomic() is now a one-line delegation to batchit::write_qs2_atomically(). The exported name, the arguments and the documented contract are unchanged, so no calling code needs to change.
One visible difference: the rename-failure error is raised by batchit, so its prefix reads write_qs2_atomically() instead of qs2_write_atomic(). Code that matches on the message text (rather than on the could not rename part) should be updated.
The batchit lockdown now bans dispatch, not the string batchit::
The test guard used to allow batchit:: only in R/batch_adapter.R. That was the wrong rule: it banned a name, when what needs confining is dispatch – target selection must stay behind the mockable .batch_* wrappers. A plain utility like write_qs2_atomically() has nothing to dispatch and nothing to mock, so confining it bought nothing and blocked this delegation.
The guard now classifies each batchit symbol:
-
Dispatch symbols (
run,run_and_collect,package_function,run_and_write_files_atomically,stream_from_parent_and_write_files_atomically,where_to_write_output) remain adapter-only. -
Primitives (
write_qs2_atomically) may be called from anywhere in swereg. - Any unclassified batchit symbol fails the guard, so a new batchit export cannot be used here without review.
Three hardenings came with the reclassification: the adapter exemption now compares the repo-relative path R/batch_adapter.R rather than the basename (a file at R/subdir/batch_adapter.R used to be silently exempt), every batchit::: reach into batchit internals is rejected outright, and the classifier is itself tested against synthetic fixtures for each branch.
swereg 26.8.3
The TTE stages now commit their outputs all-or-none
Every parallel stage of tteplan — s1a, s1b, s1c, s1d and s2 — used to hand each worker the final path of the file it was to produce, and the worker wrote there itself. A worker that died partway through left whatever it had already written sitting at the real filename, indistinguishable from a complete result. The next run then read it and carried on.
The stages now declare their output paths in the parent and let batchit commit them. A worker either returns its results (s1b, s1c, s2) or writes to a staging path it is handed at run time (s1a, s1d); in both cases the final filenames come into existence only after the item finishes cleanly.
The defect this fixes, concretely. s1d produces a matched pair — the raw enrollment file and its imputed counterpart — with minutes of imputation, IPW estimation and weight truncation between the two writes. A crash, an OOM kill or a cancelled run inside that window left a new file_raw next to a stale or missing file_imp. Nothing downstream could tell: s2 read the pair and produced estimates from two different states of the same enrollment. Both files are now committed together or not at all, so the previous pair survives a failed run intact.
What this means for you.
- An interrupted
s1_generate_enrollments_and_ipw()ors2_generate_analysis_files_and_ipcw_pp()no longer leaves a partial file that a later run mistakes for a finished one. Re-run after a crash and the result is the same as if the crash had not happened. - A missing s1a cache is now a loud error naming the file, instead of a silent recomputation that made the stage look slow for no visible reason.
- File contents, names and locations are unchanged, as is the qs2 codec. Objects written before this release read back exactly as before; no re-run is needed.
Internal: the workers no longer take output-path arguments at all, and .s1b_attrition_path() (which named a file nothing read) is gone. New contract tests cover each stage’s declared outputs and s1d’s atomic pair.
swereg 26.8.2
-
Migrated the dispatch adapter to batchit’s new public API (batchit >= 26.7.20, “naming v2”).
R/batch_adapter.Rnow callsbatchit::package_function()/run()/run_and_collect()/stream_from_parent_and_write_files_atomically()instead of the removedbatch_target()/batch_run()/batch_stream(). The internal.batch_target/.batch_run/.batch_streamwrapper names are unchanged, so the s1/s2/s3 call sites and test mocks are untouched;.batch_runroutes oncollect(TRUE→run_and_collect,FALSE→run), preserving the old per-site behaviour. Added a.batch_where_to_write_output()wrapper sostaged_writertargets resolve their output path without namingbatchit::outside the adapter. -
save_rawbatch()’s parallel path migrated to the atomic declared-output commit contract. Then_workers > 1path now dispatches throughstream_from_parent_and_write_files_atomically()withstyle = "staged_writer":.rawbatch_write_worker()writes its slice towhere_to_write_output("rawbatch")(keepingnthreads = 1per daemon) and batchit commits it atomically. The serial (n_workers == 1) path inlinesqs2_write_atomic()directly. Final rawbatch file contents are unchanged.
swereg 26.8.1
-
Phase 5′ — removed the TTE stage resume/cache heuristics.
s1_generate_enrollments_and_ipw()ands2_generate_analysis_files_and_ipcw_pp()lose theirresumeargument;s3_analyze()losesforceand now recomputes every targeted result on each call. The s1 work directory is transient dataflow, cleared at the start of every run and removed on success — no cross-run cache. Removedtteplan_s1_cache_delete()and the internal.s1_cache_key/.skeleton_manifest_on_disk/.assert_skeleton_selection/.resume_fresh/sentinel machinery. Rawbatch skip-if-exists and skeleton phase-replay caching are unchanged. Costs (accepted): a killed s1 restarts from zero; a mid-run s3 crash means full recompute.
swereg 26.8.0
Internal — the batch dispatcher is now batchit; swereg is a thin adapter
Phase 4 step 3 (PROJECT.md): the one subprocess dispatcher — signed off through Phases 0–3 and shrunk in Phase 4 step 1 — has been extracted into its own package, batchit. This was done by explicit maintainer direction (2026-07-18, target papadopoulos-lab/batchit), ahead of the recorded precondition that a second consumer (tte) first have a real call site: there is no tte package and no second consumer today. The seam that precondition guarded against — runner and consumer loading from different trees once they are separate packages — was instead proven with a synthetic throwaway consumer package in batchit’s own test suite, not by a real second consumer. swereg Imports: batchit and drives it as a plugin: the child loads the named consumer package at runtime, so there is no dependency cycle.
What moved to batchit: the whole engine — the batch_target descriptor (package + symbol + body/formals identity hash), both-ends item validation, the result envelope (protocol, id, status, value-or-error, target identity, captured warnings), the two transports (a fresh process per item via processx; a lazy producer under bounded backpressure via mirai), and the one generic worker script (now batchit’s inst/batch_worker.R, the runner-vs-consumer seam). R/batch.R, R/batch_selftest.R and inst/batch_worker.R are gone from swereg, along with the migrated contract tests (test-batch_run, test-batch_stream, test-batch_log_tail — they live in batchit now).
What stays in swereg (policy, not engine): the dispatch call sites in R/r6_tteplan.R / R/r6_registrystudy.R (which targets, progress labels, stable ids), the thread policy (.threads_per_worker), the dev-path selection (.swereg_dev_path), the consumer-code targets themselves (.rawbatch_write_worker, .process_one_batch_snapshot, the s1/s2/s3 workers), and the production-boundary proofs (test-batch_s3_production, test-batch_rawbatch_production, test-batch_skeletons_production, test-s3_item_contract). R/batch_adapter.R holds three @noRd wrappers (.batch_target / .batch_run / .batch_stream) that forward to batchit so every call site and test keeps the internal names and local_mocked_bindings keeps working.
Dev-source discriminator changed. .dev_source_root() used inst/batch_worker.R as swereg’s source-tree marker — a file now shipped by batchit, not swereg. Left alone, .swereg_dev_path() would return NULL under load_all() and the workers would silently load the stale INSTALLED swereg. The marker is now swereg’s own source: a DESCRIPTION naming swereg, no Meta/package.rds, and an R/ directory holding .R sources (an installed package’s R/ holds swereg.rdb/.rdx bytecode instead).
Lockdown tightened. With zero engine code left in swereg, the AST ban on processx/callr/mirai mentions now covers all of R/ and inst/ with no allowlist; and the lock additionally asserts that inst/batch_worker.R and R/batch.R do not exist, that DESCRIPTION names no processx/callr as a hard dependency (Imports/Depends) and no callr at all (Suggests included), and that batchit:: is mentioned nowhere in R/ outside R/batch_adapter.R. processx is permitted in Suggests because a test harness — not the package code — spawns a process directly.
Dependencies. batchit added to Imports (+ Remotes: papadopoulos-lab/batchit until it is on CRAN); processx moved Imports → Suggests (swereg no longer dispatches — the transport is batchit’s — but a qs2_write_atomic() test spawns a real process directly to prove atomicity, so R CMD check --as-cran needs it declared as a test harness); devtools dropped from Suggests (its only swereg use was the dispatcher’s dev-load, now batchit’s worker’s). mirai stays in Suggests (the mirai error-contract and rawbatch production tests use it directly). No user-facing change: the dispatcher was internal throughout.
swereg 26.7.25
Internal — high-entropy session nonce in the private mirai profile name
Corrects the 26.7.24 “private mirai profile per invocation” claim. The session-local counter alone produced .batch_stream_<n>, which is unique only among calls through the runner’s own closure — but mirai’s compute-profile registry is session-wide, and daemons() resets an existing profile of the same name. So if any other caller/package already held .batch_stream_1, the first invocation would reset it and its on.exit would then destroy it: “private” and “collision-free” were not true by construction.
.batch_stream_profile() now namespaces the counter under a high-entropy, session-specific nonce: .batch_stream_<nonce>_<counter>. The nonce is derived once (lazily, then cached) from basename(tempfile()), which embeds the pid + random hex without touching R’s RNG stream — so library code cannot disturb a caller’s set.seed()/reproducibility. A collision now requires another party to have claimed a name under the runner’s reserved .batch_stream_<nonce>_ prefix in the same session; the never-equals-"default" guarantee (defect #2) still holds by construction.
swereg 26.7.24
Internal — pre-extraction shrink of the batch dispatcher
Phase 4 step 1 (PROJECT.md): shrink the signed-off dispatcher before any batchit extraction. (The recorded sequencing put a real production run ahead of this shrink; that precondition was skipped by explicit maintainer direction — no full production rerun happened after Phase 3.) Four independent simplifications, no change to the dispatch contract the callers rely on:
Failure retention dropped entirely. Removed
keep_failed_dirfrom.batch_run()/.batch_stream(), the.batch_retain_failure()/.batch_id_slug()functions and their fail-closed chmod machinery, and the retention calls in the failure paths. Rationale: no production caller ever passedkeep_failed_dir; replay is by regeneration (stable ids + pure producers), not by persisted records; and the fail-closed0700/0600handling of a target’s own error text is sensitive-data policy, not generic dispatcher material (the joint retrospective recommended adapter-or-drop, and it is unused, so drop). The “runner never persists argument VALUES” guarantee now holds by construction — the runner persists nothing.Private mirai profile per invocation.
.batch_stream()no longer takes a caller-selectablecomputeprofile and no longer proves ownership viamirai::daemons_set(). Instead a package-local, session-local counter (.batch_stream_profile()) generates a fresh.batch_stream_<n>name each call. That name can never be"default", so the never-touch-the-default- profile guarantee (defect #2) holds by construction, deleting the collision policy, the fail-closed ownership predicate and their tests.save_rawbatch()no longer passescompute = "swereg_rawbatch"— the runner owns profile allocation.inst/batch_worker.Rslimmed from 119 to ~72 lines (45 code). The envelope is read once (the second read and the id-extraction preamble are gone) and the 32-linetryCatchfallback error-envelope writer is removed. New failure contract: any failure at or before.batch_execute()writes nothing and exits non-zero — the parent’s exit-code channel plus the per-item log tail is the diagnostic path. The signed-off security properties are unchanged: exact[[extraction throughout, and the package-independent pre-load structural check of the fields that decide what code loads. Target- level failures still return a structured error envelope (exit 0) via the unchanged, total.batch_execute().Shared input-envelope constructor + comment prune. Both frontends now build the wire envelope through one
.batch_input_envelope(), so the schema cannot drift between them. Review-archaeology comments (which round found what) inR/batch.Rwere cut to the live constraint they document; every constraint comment stays.
swereg 26.7.23
Bug fixes
-
The dev-path probe no longer mistakes an installed package for a dev source tree under R CMD check.
.swereg_dev_path()decided “installed vs source” withany(startsWith(system.file(), .libPaths())), which is fragile: under R CMD check the package is loaded from a check library whose realpath- normalized form is not a string-prefix ofsystem.file()’s recorded path, so the probe took the INSTALLED.Rcheck/sweregdir for a dev tree and handed it to the batch dispatcher asdev_path. The dispatcher then soughtinst/batch_worker.R(absent — install promotesinst/*to the package root) and asked mirai daemons todevtools::load_all()an installed layout (which cannot resolve.batch_execute) — the fourtest-batch_*_production/test-process_skeletons_loud_errorsfailures that appeared only on CI, never underload_all(). Two guard layers, both structural rather than heuristic:-
.swereg_dev_path()now discriminates via markers R itself writes: an installed package carriesMeta/package.rdsand has noinst/subdir; a source tree is the converse. Split into a unit-testable.dev_source_root(). -
.batch_validate_dev_path()(the runner-level guard) now REJECTS an installed-package dir loudly (Meta/package.rdspresent), rather than limping on to a deeper “worker script not found” — defect #5 (a wrongdev_pathmust error, never silently run different code).
-
swereg 26.7.22
Breaking changes
-
parallel_pool()is removed (it was exported). Every parallel pipeline stage now dispatches through the internal generic batch runner (the two explicit serial branches –save_rawbatchandprocess_skeletonsatn_workers = 1– call the same targets in-process; every other path launches batch-runner subprocesses even with one worker); there is no supported external entry to the old pool.callris no longer a dependency.
New features / internal
-
Phase 3 of the dispatcher unification: every parallel work dispatch in the package now crosses the ONE batch contract, and the legacy engines are deleted. What “one dispatcher” claimed in 26.7.21 is now true at every process boundary the package creates for pipeline work (the two deliberate serial branches –
save_rawbatch/process_skeletons– run the same targets in-process; the only other child process is agit rev-parsemetadata shell-out):-
s3_analyze()’s ETT loop joins the enrollment loop on.batch_run(one method no longer carries two dispatch contracts). Two builder fixes the mechanical swap would have missed: items now carry the per-worker thread share themselves (they used to sayn_coresand rely onparallel_pool()overwriting it — carried verbatim that would have oversubscribed every worker), and the optionalsubgroup_varformal is explicit on every item (the every-formal rule). -
s2 and s1a–s1d migrate mechanically; all items get stable, meaningful ids (skeleton basename, enrollment id, analysis-file basename,
enrollment__skeletonfor the 39k-item s1c stage) so a failure names the exact unit of work that died. -
save_rawbatch()— the shape-B production proof Phase 2 lacked — drops its hand-rolled mirai block for.batch_streamon the dedicatedswereg_rawbatchprofile, with the new.rawbatch_write_workertarget as the one rawbatch write path in both modes: serial (n_workers = 1, the default) calls it in-process — no process boundary, no mirai requirement — and parallel dispatches it. The daemon-side hand-inlined copy of the atomic write (which had already drifted once) is gone; the target uses the realqs2_write_atomic(). -
process_skeletons()drops itscallr::r_bgengine for.batch_runwith the new.process_one_batch_snapshottarget: the study is written to ONE snapshot file per run and each item carries only its path plus small scalars — a naive migration putting the ~5.7 MB study into each of 2,194 eagerly-materialised item envelopes would have serialized ~12.5 GB before the first worker launched. Pinned structurally (one snapshot, items < 50 KB, cleaned on unwind) bytest-batch_skeletons_production.R. -
Deleted:
R/parallel_pool.R,inst/worker_bootstrap.R, all eightinst/worker_*.Rdispatch scripts (plus the dead.s1a_worker()),test-worker_arg_parity.Randtest-parallel_pool_io.R(guarantees ported: chatty-worker no-deadlock and per-item log reclaim re-proven against.batch_run;.pp_log_tail()moved intoR/batch.Rwith its unit tests). The no-direct-qs_saveguard now covers ALL ofR/(the two engines whose deliberate raw writes forced its narrow scope are gone). -
The lockdown (
test-batch_lockdown.R): a parse-based sweep ofR/andinst/bans any processx/callr/mirai mention outsideR/batch.R, proven non-vacuous againstbatch.Ritself and proven red against a planted engine call. Old workers cannot reappear;callrcannot return toDESCRIPTIONunnoticed.
Production-boundary proofs run REAL subprocesses end to end:
test-batch_s3_production.R(s1→s2→s3 with both s3 loops asserted),test-batch_rawbatch_production.R(daemons write + round-trip real slices; a blocked rename surfaces naming the batch), andtest-batch_skeletons_production.R(skeletons built from the snapshot). -
swereg 26.7.21
New features
-
One generic subprocess dispatcher (
R/batch.R) with a contract validated at both ends (Phase 2 of the dispatcher unification, towardbatchit). swereg dispatched work to subprocesses through three hand-rolled engines (parallel_pool/processx,callr, amiraibounded queue) with no enforced parent/child contract; Phase 1 fixed 20 boundary defects but left the engines. This release adds the single runner they will collapse into:-
.batch_target(package, symbol)— a target is a descriptor (package + symbol + a hash of the function’s body and formals via.hash_function()), never a closure. The child re-resolves it and refuses to run if its hash differs from what the parent dispatched (closing the stale-dev-code hole). -
.batch_run(target, items, n_workers, ...)— shape A: items already exist, one fresh subprocess per item (the memory strategy for ~20 GB/worker stages). Evolved from the hardenedparallel_pool(). -
.batch_stream(target, ids, producer, n_workers, ...)— shape B: the parent is the producer, items are big data slices generated lazily undermiraibackpressure and passed in-memory. Ownership of the compute profile is checked withdaemons_set()(never hijacks a profile the caller configured; never the default profile). -
inst/batch_worker.R— the one generic worker, replacing the eight hand-writteninst/worker_*.Rdispatch scripts and the 100-line regex parser that verified them.
The contract: every formal named explicitly (including optional ones — this is what makes the
arm_labels-dropped class of bug impossible, not merely documented); validation at both ends, with total input and result inspectors (a hostile/corrupt envelope becomes a structured failure, never a crash) and exact[[extraction (no$partial-matching steering which code a worker loads); a private matched IPC codec; target warnings captured and re-surfaced in the parent; fail-closed metadata-only failure retention (never argument values); a per-item timeout; and a pre-load structural gate in the worker so a malformed envelope cannot steer loading.s3_analyze()’s enrollment loop is migrated onto.batch_runas the production-boundary proof:test-batch_s3_production.Rdrives the reals3_analyze -> .batch_run -> generic worker -> .s3_enrollment_workerpath end to end and asserts both a real baseline result witharm_labelsforwarded AND that a corrupt analysis file makess3_analyze()raise. The remaining call sites (save_rawbatch, the s1/s2/s3-ETT/skeleton loops), deleting the old engines, and extractingbatchitare Phase 3+.miraifloor raised to>= 2.3.0(fordaemons_set()). Adversarially reviewed by codex (model_reasoning_effort=high) over eight rounds; every fix landed with a test demonstrated to fail without it, and the production boundary is tested through the real worker, not helpers. -
Bug fixes
-
s3_analyze()silently discardedarm_labels, so every Table 1 it built used default arm headers instead of the spec’s. The item builder ins3_analyze()computedarm_labels = .lookup_arm_labels(self$spec, eid)into every enrollment item, and.s3_enrollment_worker()accepted it (arm_labels = NULL) – butinst/worker_s3_enrollment.Rnever forwarded it fromparams, so the target always took its default.s3_analyze()callsparallel_pool()unconditionally (there is non_workers == 1serial branch), so this affected every run at every worker count, whilerecompute_baselines()– which calls the same target directly, witharm_labels– produced the correct labels. Two methods that build the same table disagreed, and the pipeline ran the wrong one.Impact is labelling, not estimates: Table 1’s comparator/intervention column headers. No numbers move.
recompute_baselines()repairs an existing plan without re-running s3.Nothing caught this because the guard was pointed at the wrong half.
test-worker_arg_parity.Rchecked that a worker passes no argument the target rejects – which fails loudly, at the first item, withunused argument. It never checked that a worker forwards every argument the target accepts – whose failure is silent, because the target simply takes its default. The mirror check is now in place and is demonstrated to fail without the fix. A formal a worker never forwards is unreachable from production: either it is a dropped field or it is dead code, and both now error. A chatty worker could deadlock
parallel_pool()forever, looking exactly like a hung stage.stdoutandstderrwere pipes (stdout = "|") that were only read after the child exited. A pipe has a fixed OS buffer (64 KB on Linux), so a child that out-wrote it blocked inwrite(), never exited, and stayedis_alive() == TRUE– the dispatch loop then span on it until killed. Reproduced before the fix: 1 KB per stream finished in 0.7s, 100 KB never returned. Workers now write to a per-item log file, and a failure reports a genuinely bounded tail of it: at most the last 64 KB are read from the end of the file, so a worker that dies after emitting a multi-GB log cannot OOM the parent while its error is being reported. Each successful item’s log is reclaimed as soon as it finishes rather than at pool exit – s1c dispatches 39,492 items over ~10h, and deferring cleanup would sit on ~39k files for the whole stage.detectCores()returningNAno longer poisons thread counts. It is documented to returnNAwhen it cannot determine the core count; unguarded, thatNApropagated intofloor(NA / n_workers)and surfaced much later inside a worker’ssetDTthreads(), a long way from the cause – or went straight into qs2’snthreads. All eight call sites (s1, s2,s3_analyze(),process_skeletons(), serialsave_rawbatch(),TTEPlan$save(),Skeleton$save(),parallel_pool()) now route through one guarded.safe_n_cores()/.threads_per_worker(), and a test fails if any future code callsparallel::detectCores()directly.Worker-count validation now runs at the very top of every entry point, before any early return or object mutation. Fractional counts used to be silently truncated (
as.integer(2.5)= 2) because callers converted before checking; and the check, where it existed, ran too late –save_rawbatch()’s “group already saved” return,s1’sself$output_diroverwrite, andprocess_skeletons()’s manifest invalidation all preceded it, so an invalid count could report success, or leave the object half-modified, or destroy the committed skeleton manifest and only then error. All six entries (parallel_pool(),s1,s2,s3_analyze(),save_rawbatch(),process_skeletons()) now call a shared.validate_n_workers()as their first statement, and it names its caller in the error.default_n_workers()likewise validates a configured value rather than repairing it. The validator also rejects a whole number above.Machine$integer.max: it would otherwise pass every other check and becomeNAonas.integer(), and thatNAthen flowed past validation into callers that clear state before their ownn_workers <= 1test – so a “rejected” count could still destroy the committed manifest.resumecould skip a future-dated file forever. The freshness test wasage <= 24h; a file dated in the future has a negative age, so it satisfied that on every run, indefinitely. Freshness now requires a finite, non-negative age. Two hosts mount the same share here, so clock skew makes this reachable rather than theoretical.A
swereg_dev_paththat exists but is the wrong package is now rejected. The path was only checked for existence, then itsinst/scripts were executed and the treeload_all()ed into every worker – so a mistargeted or renamed directory loaded the wrong package and mixed it with the installedswereg. The tree’sDESCRIPTIONmust now nameswereg.parallel_pool(n_workers = 0)span at 100% CPU forever instead of erroring.floor(n_cores / 0)gaven_threads = Inf, and the dispatch loop never launched anything (length(active) < 0is never true) while itsSys.sleep(0.1)sat insideif (length(active) > 0L)– an infinite busy loop, silent and invisible.n_workersis now validated as a single finite whole number >= 1 before anything is divided by, launched, or written.A
swereg_dev_paththat did not exist silently ran the installed package instead.is_devsimply wentFALSEand execution fell through tosystem.file(), so a typo’d dev path ran different code than you asked for and reported success. Asking for a dev tree that is not there is now an error; passswereg_dev_path = NULLto use the installed package deliberately.-
Ten writes went straight to their final path, bypassing
qs2_write_atomic(). Panels, counts,file_raw,file_imp,file_analysisandTTEPlan$save()all usedqs2::qs_save()directly, while resume logic trusts those files’ existence – so a killed worker could leave a truncated.qs2that a later run then skipped as “already done”. All ten now route throughqs2_write_atomic(), and a test fails if any future write bypasses it again.Scope, stated precisely rather than broadly: this means an interrupted process can no longer create a torn final path. It does not make resume safe in general. Resume still trusts existence and mtime, which cannot tell you that a file came from the current inputs, spec, target body or package version, nor that it was not left by a pre-existing run. Resume is only sound when inputs and code are unchanged; completion records tied to input/target identity are future work.
s2resume could reuse arbitrarily old analysis files. The age check tookmax(mtime)across all existing outputs and, if that single newest file was under 24h, skipped every existing file – so one 1-hour-old output caused a 100-hour-old output to be reused, while the log claimed “analysis files <24h old” about files that were nothing of the sort. Each file is now aged on its own, via a new internal.resume_fresh(), and the message reports skipped and redone counts separately. A file whose mtime cannot be read is no longer treated as fresh.qs2_write_atomic()was weaker than its own documentation. Its temp file waspaste0(path, ".tmp", Sys.getpid()), but PIDs are unique only among live processes on a single host – and this package’s data lives on a CIFS share that more than one host mounts at once, so two machines could pick the same temp path for the same target. It now usestempfile()in the destination directory (same directory is required:file.rename()is not atomic across filesystems), and cleans up the partial file on an R-level failure – though not after aSIGKILL, whichon.exit()cannot survive: a hard-killed worker leaves its temp file behind, and only the destination is guaranteed absent-or-complete. Its docs now state what it does not promise: it is not durability (rename is notfsync) and it is not a lock (concurrent writers each produce a complete file; the last rename wins).save_rawbatch()’s mirai daemon hand-inlines the same temp+rename (the daemon may not have swereg loaded to call the function), and was still carrying the old PID-based name; it now inlines thetempfile()-based form too, and removes its partial temp on any R-level failure (previously only on a rename failure, so aqs_save()that errored mid-serialization left a partial – potentially sensitive – rawbatch slice in the shared directory).save_rawbatch()destroyed the caller’s mirai daemon configuration. It calleddaemons(n)/daemons(0)on mirai’s default compute profile, which resets whatever the caller had already set up – verified: a caller holding 2 daemons was left holding 0. It now claims a named profile (swereg_rawbatch), per mirai’s guidance to package authors, and a caller’s default daemons survive untouched.-
miraiwas undeclared.save_rawbatch(n_workers > 1)callsmirai::daemons()and friends, but mirai appeared nowhere inDESCRIPTION– anR CMD checkwarning, and the reason the parallel path died on a machine that had never installed it. Now inSuggests, which matches the contract: worker counts default to serial, parallelism is explicitly opt-in, and absence already produces a clear runtime error.drain_one()now readscall_mirai(h)$dataand tests it withis_error_value()– mirai’s documented API – rather than reading the undocumented sibling binding$valueand hand-checking classes. This is hygiene, not a bug fix: both bindings exist and areidentical(), and the original guard was verified to fire correctly on failure and stay quiet on success. Newtest-mirai_error_contract.Rpins that behaviour, because it is the failure path and no happy-path production run ever exercises it.
swereg 26.7.19
Bug fixes
-
s1 resume caches are now keyed on the inputs s1’s output depends on, not the spec alone.
{data_meta_dir}/s1_work/{project_prefix}/{cache_key}/previously used a hash of the parsed spec and nothing else. Since s1’s output depends on the spec and the skeletons it reads, regenerating skeletons under an unchanged spec produced an identical key, andresume = TRUEreused s1a/s1b/s1d sentinels computed from the previous skeletons – silently, with nothing downstream able to notice. Production-reachable: projects that passresume = TRUEwould hit this after any reclean.The key now covers: the spec; the committed skeleton manifest identity; the ordered batch IDs this run actually selects (
n_skeleton_filescaps what s1 reads while leaving the directory unchanged, so a capped run and a full run previously shared a key – the capped run’s enrollment-level sentinels could be reused by the full run, making matched comparators silently represent only the subset);impute_fn;stabilize;output_dir(s1d skips on its sentinel, so resuming with a different destination would skip s1d, delete the work dir, and report success having written nothing there); and the swereg version. The digest is now the full 64 bits rather than a 48-bit prefix..spec_cache_key()is replaced by.s1_cache_key(), and.s1_work_dir()’sspec_hashargument is renamedcache_key– it is no longer a hash of the spec. Both internal.Two known limits, stated rather than papered over. The swereg version is a weak proxy for s1’s implementation, because
swereg_dev_pathloads a dev checkout whose code need not match any released version – bump the version for any change to s1 semantics. Andimpute_fnis hashed by body and formals only, so two closures with identical bodies but different captured values key identically; that covers the package’s own defaults but not a custom callback carrying state. resume = FALSEnow clears its own keyed work directory before starting. It previously overwrote artefacts in place, so a process killed mid-overwrite could leave an old sentinel beside a partially-rewritten cache – which a laterresume = TRUEwould read as completed work. Only this key’s directory is touched, and a failure to remove it now raises rather than proceeding. Note a cache key identifies a configuration, not a run: a concurrent s1 with the same key shares the directory, which the single-writer invariant below covers and this code does not.
New behaviour
-
$process_skeletons()now commits a skeleton manifest, and$s1_generate_enrollments_and_ipw()requires one to resume. The manifest is a newskeleton_manifestfield onRegistryStudy, written into the existingregistrystudy.qs2by the existing$save_meta()(which is already atomic viaqs2_write_atomic()) – no new file, no new machinery.It is cleared before any batch is touched and re-committed only if the finished dataset validates, so an interrupted or failed run leaves no manifest rather than a stale one vouching for skeletons it no longer describes. Committing requires all four of: every batch’s provenance readable (its meta sidecar, or the skeleton itself where none exists – note this does NOT open every skeleton, which would mean reading GBs per run, so a skeleton replaced while its old sidecar survives is not detected; both writes are atomic but they are separate writes); exactly one distinct
pipeline_hash; that hash equal to the study’s current$pipeline_hash()(internal agreement is not currency – a uniformly obsolete dataset would otherwise pass); and batch IDs exactlyseq_len(expected)(a count cannot tell 1..N from 2..N+1, and a first build interrupted at batch 272 leaves 272 mutually-consistent skeletons that a hash-only check waves through, after which s1 would analyse 12% of the cohort and look fine doing it).batchescontrols only what this run processes, never what is validated – validation always looks at the whole directory, because that is what s1 will read. So a subset run (batches = 1:10) still commits if the resulting whole dataset validates, and simply leaves no manifest if it does not. The difference is that a full run (batches = NULL) which fails to validate raises: otherwise a caller’s file-count gate reports success over a dataset nothing will accept.The manifest records
manifest_version,committed_at,swereg_version, the exact orderedbatches, thepipeline_hash, and anidentitydigest over the ordered per-batch(batch, pipeline_hash, built_at)triples.identityis what makes this a data identity rather than a code one:pipeline_hashis derived from function hashes, so rebuilding from changed raw data with unchanged code leaves it identical, whereasbuilt_atmoves on every save.s1 re-reads
registrystudy.qs2from disk to get it –plan$registrystudyis a copy frozen when s0 saved the plan, and would describe whatever the skeletons were then. One small read, replacing a per-batch sidecar scan (~40 s for 2,200 batches over SMB), so s1 startup is faster as well as safer.Migration: existing skeleton datasets have no manifest, so
resume = TRUEwill refuse until$process_skeletons()has completed once under this version.resume = FALSEis unaffected – it reads no cache, so there is nothing to match. Skeletons that are baredata.tables rather thanSkeletonobjects (a supported s1 input) carry no provenance and can never have a manifest; they too are limited toresume = FALSE.SINGLE-WRITER INVARIANT (not enforced). Exactly one
$process_skeletons()may run against a skeleton directory at a time, and no s1 may read it while one does. Two concurrent writers can interleave as clear(A), clear(B), commit(A), B-replaces-skeletons-and-dies, leaving A’s manifest vouching for B’s skeletons – a logical race that atomic writes cannot fix. Likewise s1 can read a valid manifest immediately before a regeneration starts. Serialise the stages. New
tteplan_s1_cache_delete(plan, dry_run = TRUE)deletes a project’s s1 resume caches. s1 removes its own work directory on success, so leftovers are from killed or superseded runs; now that the key covers skeleton state, every regeneration orphans one. Deliberately explicit rather than automatic at s1 startup: only the run that created a work directory knows whether it is finished with it, deleting another run’s work would break a concurrent s1, and a killed run’s cache is the only evidence of what it did. Deleting a cache is never a correctness risk – it is an accelerator, not an artefact, so the worst case is that s1 redoes work.
swereg 26.7.18
Breaking changes
-
Removed the pipeline-snapshot feature.
RegistryStudy$write_pipeline_snapshot(), thedata_pipeline_snapshot_dirconstructor argument, thedata_pipeline_snapshot_cp/data_pipeline_snapshot_dirfields, and the now-orphanedhost_labelfield are all gone. It wrote a one-row per-host TSV ofpipeline_hash/framework_fn_hash/all_batches_consistentinto the caller’s git repo — write-only provenance that nothing read back, and whose “is this host consistent?” claim went stale silently the moment a caller stopped passingdata_pipeline_snapshot_dir(the default), because the writer no-ops on NULL.$compute_summary()/data_summaries_diralready answer the same question more usefully, by diffing actual per-variable output rather than a hash of the code.Migration: drop
data_pipeline_snapshot_dir = ...from anyRegistryStudy$new()call; it is now an unused argument. Nothing else changes.The schema version stays at 5 — deliberately. The removed fields were optional, NULL by default, and carried no data anyone reads back, so no
registrystudy.qs2on disk becomes invalid. Bumping would have forced every host to regenerate its meta file (and, per the downstream convention that a missingregistrystudy.qs2re-stages rawbatch, potentially a great deal more) for a purely cosmetic removal. -
DiagrammeR,DiagrammeRsvgandrsvgmoved fromImportstoSuggests.R/consort.Ris their only consumer and it already guarded them withrequireNamespace()and degraded to a warning when absent — so they were declared mandatory while the code treated them as optional. The declaration was wrong.This matters on Linux:
DiagrammeRsvgneedsV8, which needs a systemlibnode-dev, so on a box without it every swereg entry point that loads the package —devtools::load_all(),roxygen2,R CMD check, the test suite — failed, purely to render a CONSORT diagram. Windows/macOS are unaffected either way (CRAN ships self-contained binaries).Migration: if you render CONSORT diagrams on Linux, install the optional stack explicitly —
pak::pak(c("DiagrammeR", "DiagrammeRsvg", "rsvg"))plusapt install libnode-dev. Without it,$s4_export()still produces every other output and emits one warning naming exactly what to install.
Bug fixes
-
qs2_write_atomic()is now actually exported. It was added in 26.7.17 with an@exporttag, but NAMESPACE was never regenerated, soswereg::qs2_write_atomic()errored with “not an exported object”. Internal callers use it unqualified and were unaffected — atomic writes themselves worked as documented.
Changes
-
Atomic qs2 writes.
RegistryStudyandSkeletonnow serialize skeletons, rawbatch slices, theregistrystudy.qs2meta, summaries and population tables to a temp file andfile.rename()into place (newqs2_write_atomic(); the rawbatch mirai worker inlines the same temp+rename). An interrupted worker (SIGKILL, crash, dropped CIFS mount) can no longer leave a truncated.qs2at the final path, so aprocess_skeletons()resume never halts on “unexpected end of file”. file.rename() verified atomic on SMB/CIFS.
swereg 26.7.15
Changes
-
The “PP vs ITT forest” exhibit is now “ITT vs PP forest” (ITT first). The head-to-head sheet, its numeric table, and the two-colour overlay now put intention-to-treat first / on the left; per-protocol moves to the right. Colours are unchanged — PP stays red, ITT stays blue — so after the swap the left column is blue (ITT) and the right is red (PP). The sheet name, title, TOC entry, and sidecar filename (
…_forest_plot_itt_vs_pp) follow, as do the internal writers/renderer (.build_itt_vs_pp_df,.write_itt_vs_pp_forest,.render_itt_vs_pp_overlay). Callers that only use$export_tables()need no change; the old…_pp_vs_ittsidecar filename is retired.
swereg 26.7.14
Improvements
-
Role sub-headers now reach every forest renderer. The
role_headersgrouping added in 26.7.13 (for$export()forest figures) is now also honoured by$export_tables()via a newforest_role_headersargument. The “PP forest plot” / “ITT forest plot” sheets and the two-series “PP vs ITT forest” overlay all thread the same exposure → role → outcome hierarchy, so every forest in the workbook matches the standalone publication figures. Backward compatible:forest_role_headersdefaults to NULL.
swereg 26.7.13
Improvements
-
Forest plots can group outcome rows by role.
$export()forest specs gain arole_headersfield — a named map from anoutcome_rolevalue to a sub-header label (e.g.role_headers = list(primary = "Primary outcome", secondary = "Secondary outcomes")). When supplied (and the forest groups by exposure, i.e. outcomes are the rows), a bold-italic sub-header is threaded into each exposure block whenever the role changes, and the outcome rows indent beneath it, giving an exposure → role → outcome hierarchy. Pair it with an explicitlabel_format = "{outcome_name}"so the role names the group instead of riding along in each row label as(primary)/(secondary). Backward compatible:role_headersdefaults to NULL, leaving the two-tier exposure/outcome layout (and every existing forest) byte-identical.
swereg 26.7.11
Improvements
-
s1 progress bars now self-identify the sub-stage.
parallel_pool()gains alabelargument that is prefixed to the per-item progressor message, andTTEPlan$s1_generate_enrollments_and_ipw()passes"s1a"/"s1b"/"s1c"/"s1d"to its four calls. The live bar’s(last: ...)slot now reads e.g.(last: s1c 09:33:18), so atailof a job log mid-run tells you which of the four s1 sub-stages is active (s1a scout, s1b match, s1c panels, s1d IPW/save) instead of a bareN/Mbar. Backward compatible:labeldefaults toNULL(timestamp only), so other callers (s2/s3/skeleton) are unchanged.
swereg 26.7.9
Features
-
TTEPlan$export(manifest)— one declarative, manifest-driven entry point for study exhibits (figures and tables). Pass an ordered list of specs; each is produced in order and written with a two-digit prefix, so the manifest order becomes the exhibit numbering. Each spec’styperoutes it to a private producer:-
figures (
.export_figure):"survival"(weighted survival curve for one ETT cell, one image per estimand — PP fromfile_analysis+analysis_weight_pp_trunc, ITT fromfile_analysis_itt+ipw_trunc; loaded analysis objects are re-wrapped under the current class so they pick upsurvival_curve()) and"forest"(forest plot over a namedexposuresset). -
tables (
.export_table):"table1"(baseline characteristics for an enrollment, written as CSV).
Projects declare their exhibit set once; the same driver serves every project with a different manifest. The existing
$export_tables()full-bundle writer is unchanged.Forest exhibits also support:
spec$group_by="exposure"(default; exposure is the group header, outcomes are the rows) or"outcome"(outcome is the group header, exposures are the rows); and an outcome role shown from metadata. Addrole: primary/role: secondaryto a spec outcome and it surfaces via the new{outcome_role}label field (and by default on the row label when grouping by exposure) – the outcomenamestays clean. Forest inputs are validated (fully-namedexposures, all ETT ids known). -
figures (
TTEEnrollment$survival_curve(weight_col, save_path, title)— weighted discrete-time survival curve computed on the person-week panel. Per arm and period it forms the weighted hazardh(t) = sum(w * event) / sum(w)from the (optionally time-varying)weight_coland returnsS(t) = prod(1 - h(t)). Because it works on the full panel rather than one row per subject, it accepts time-varying weights: pass a baseline IPW column for the ITT/IPW curve, or a per-protocol weight (e.g."analysis_weight_pp_trunc") for the PP curve — which the old$km()could not do. It is a descriptive weighted curve (not the MSM-standardised survival estimator). Deaths are censored (no competing-risk adjustment), sosurvis cause-specific event-free survival under independent censoring and1 - survis not a real-world cumulative incidence; label it accordingly. Inputs are validated (weight must be finite/non-negative;eventmust be a non-missing 0/1 indicator).
Breaking changes
-
$km()has been removed and replaced by$survival_curve(). The old method usedsurvey::svykmon one row per subject (baseline IPW only); the panel estimator generalises it and additionally supports per-protocol (time-varying) weights. Call$survival_curve(weight_col = ...)instead; the return type is adata.tableof(treatment, tstop, hazard, surv)rather than ansvykmobject.
swereg 26.7.4
Documentation
-
vignette("tte-methods")restructured so the SAP reads like a SAP. Section 2 (statistical analysis plan) is now implementation-agnostic: function names, argument names, option names, and test-file paths have been moved out of the SAP prose into a new Section 4 (“Implementation mapping”), which tabulates SAP step -> code (function/argument/option), records estimator-behaviour provenance (the 26.7.3 event-priority change, theadmin_censor_varerror), and maps each validation layer to its test file and opt-in environment variable. -
Validation evidence is now hard numbers, not prose claims. Section 3 renders 17 tables and 6 figures from a committed results artifact (
vignettes/tte-validation-evidence.rds) containing truth, estimate, 95% CI, log-scale bias, per-replicate draws, and coverage counts for every validation cell: the cross-package triangle (truth vs swereg vs TrialEmulation, single fixed-seed datasets plus a 20-replicate version whose mean bias is plotted with Monte Carlo error bars, for both truncated and untruncated swereg weights — the contrast attributes the s3 per-protocol residual to weight truncation under informative loss), the stress matrix (rare outcomes, null effect, informative attrition, depletion of susceptibles, truncation-attenuation dose response, treatment-confounder feedback, determinism), the plan-layer factorial plus an 8-seed Monte Carlo (per-replicate and summarised), the M=200 ITT coverage study (with a caterpillar figure of all 600 replicate CIs), and a boundary-of-validity section (3.8): a truncation-tradeoff grid varying loss informativeness, selection direction, and effect direction, two cells in which selection is driven by an unmeasured prognostic factor (dropout on U and a healthy-adherer adherence mechanism), a feedback cell where all weighting/conditioning approaches fail, and bias/spread/RMSE figures grounding the recommendation of truncated-primary plus untruncated sensitivity. The section also documents the validation design itself: how counterfactual truth is simulated, the data-generating processes as equations, per-scenario nuisance parameters, and realized descriptives of every analysed dataset. Numbers quoted in the prose are computed inline from the same artifact, so they cannot drift from the tables. -
New
dev/generate_validation_evidence.Rregenerates the artifact by rerunning every validation cell through the same DGP/truth/fit helpers the testthat suite sources (tests/testthat/helper-tte_*.R); rerun it after any estimator change and commit the refreshed artifact. -
scen_fit_te()(test helper) now also returns the converted CI bounds (lo,hi), used by the artifact generator; existing tests are unaffected.
swereg 26.7.3
Bug Fixes
-
Per-protocol events colliding with protocol deviation are no longer dropped. When a person-trial’s first outcome event fell in the same enrollment band as its first protocol deviation,
s5_prepare_outcome()flagged the row as bothevent = 1andcensor_this_period = 1, and the post-IPCW censoring-row drop ins4_prepare_for_analysis()silently deleted the event. The discrete-time convention is event-priority: the outcome is measured over the interval before within-interval censoring applies, so the band now counts as an event (and the censoring model no longer sees it as a censoring). In a switching-heavy simulation (persist_coef = 2, 76% of trials switching) the old behaviour lost 9.6% of legitimate per-protocol events; after the fix the pipeline’s event count matches the ground-truth event-priority count exactly. Low-switching analyses shift much less. ITT is unaffected (it never censors at deviation, and end-of-data loss cannot collide with an event by construction). Regression test:test-pp_event_deviation_collision.R. -
admin_censor_varnow fails loudly instead of being silently ignored.TTEDesignaccepted and storedadmin_censor_var, but no code ever used it – callers requesting per-person administrative censoring got none.s5_prepare_outcome()now stops with a clear message directing toadmin_censor_isoyearweek. -
Robust treatment-coefficient lookup in
$irr(). The Poisson MSM extracted the treatment coefficient as<treatment_var>TRUE, which fails cryptically (“subscript out of bounds”) when the treatment column is numeric 0/1 rather than logical/factor. The lookup now falls back to the bare variable name and otherwise stops with the available coefficient names.
New Features
-
Prevalent-user guard in
tteplan_read_spec(). The enrollment engine has no built-in new-user rule: without a washout exclusion on the treatment variable, prevalent users enrol as “intervention” at every eligible band and discontinuers re-enter as comparators – a prevalent-user design, almost never the intended estimand (and the exact failure mode documented in the downstream project’s v006 spec changelog). Reading a spec where an enrollment has neither an exclusion oftype: "no_prior_intervention"nor any exclusion referencing its treatment variable now warns. Both washout styles satisfy the check: a finite look-back window in weeks (the Danaei 2013 convention, which allows re-qualification after time off treatment) or a lifetime look-back (never-user design). Suppress withoptions(swereg.warn_prevalent_user = FALSE). Tests:test-spec_newuser_warning.R. -
Known-truth stress-test infrastructure. The synthetic-truth validation triangle (planted truth vs swereg vs TrialEmulation) is extended from the original three-scenario matrix to a permanent adversarial battery:
-
test-tte_stress_matrix.R(enrollment layer): null effect, harmful effects under depletion of susceptibles, rare outcomes (~0.25%/band), near-positivity violation with a truncation-severity sweep, heavy informative attrition (~73% of person-periods lost), time-varying confounding with treatment-confounder feedback (time-updated vs frozen censoring covariates), and bit-identical determinism. Heavy cells are opt-in viaSWEREG_RUN_STRESS=true. -
test-tteplan_truth_matrix.R(plan layer): the full production path – YAML spec, skeleton files (including an ISO week-53 year), sequential eligibility, per-band matched enrollment, IPW, PP/ITT analysis files, weightedsvyglm– recovers a planted IRR of 2.0, with factorial coverage of baseline confounding and independent/informative loss to follow-up plus a discontinuation (PP vs ITT separation) scenario. Heavy cells opt-in viaSWEREG_RUN_PLAN_MATRIX=true.
-
Documentation
-
vignette("tte-methods")rewritten as manuscript + statistical analysis plan text, matched to the verified implementation. The manuscript section now covers both estimands (ITT and per-protocol). The former supplemental section is upgraded to a full SAP: it documents the estimators exactly as implemented – including the marginal (not covariate-conditional) IPCW stabilization numerator, the inclusive cumulative-product IPCW convention (with rationale: censoring-band rows are removed, so a row at band k exists iff uncensored through k), the actual outcome-model formula with its follow-up-time and trial-index splines, the event-priority convention, hot-deck single imputation, weight-truncation defaults and the positivity/truncation bias tradeoff (with simulated attenuation magnitudes), identifying assumptions per estimand, interpretation of the IRR as a person-time-weighted average under non-proportional hazards, and known limitations (no grace periods/cloning, no as-treated, variance conditional on estimated weights). A validation-evidence section summarises the cross-package matrix, the stress matrix, the full-pipeline truth recovery, and the Monte-Carlo coverage calibration. -
vignette("tte-methodology")corrections. The paper-mapping table no longer describesperiod_widthas a grace period (a true Hernán-2016 grace period requires cloning, which is not implemented; the band width only gives within-band slack at enrollment). The follow-up table documents the event-priority collision rule.
New Features
-
Publication workbook: real numbers + per-estimand restructure.
TTEPlan$export_tables()reorganises the results sheets around estimand and writes every measurement as a real number formatted in Excel – so cells sort, sum, and no longer trip the “number stored as text” warning:- The headline results sheets are now PP forest plot, ITT forest plot, PP results, ITT results, and PP vs ITT forest – the last a numeric
PP IRR/ITT IRRhead-to-head table above a two-colour overlay forest (red per-protocol, blue intention-to-treat, dodged per outcome). The truncated-vs-untruncated weight comparison (formerly “Full results”) moves to a supplementary Weight truncation (PP) sheet. - events (
0.0), PY (#,##0), rate (0.0) and p-value ([<0.001]"<0.001";0.000, so tiny p reads<0.001not a misleading0.000) are bare numerics carrying Excel number formats. IRR + 95% CI stay human-formatted display strings – they are inherently composite, like Table 1’s “n (%)” cells, which also remain display strings. CONSORT attrition counts gain a thousands separator. - Forest-plot figures are unchanged: their text panels are rendered from the same numerics through the plot’s own formatters (
.ff_num,.ff_irr_ci), fully decoupled from the worksheet cells. - Forest-plot sidecar files follow one consistent scheme:
{root}_forest_plot_{pp,itt,pp_vs_itt}.{png,pdf}.
.write_results_single(),.write_pp_vs_itt_forest()and renderer.render_pp_vs_itt_overlay()back the new sheets. This is ans4re-export change only – no pipeline (s1/s2/s3) rerun needed. - The headline results sheets are now PP forest plot, ITT forest plot, PP results, ITT results, and PP vs ITT forest – the last a numeric
swereg 26.6.20
Bug Fixes
-
Locale-proof spec reading.
tteplan_read_spec()previously calledyaml::read_yaml(), which reads viareadLines()and, under a non-UTF-8 session locale (e.g.LC_CTYPE=C, common in headless/cron/CI runs), silently truncated the YAML at the first non-ASCII byte — dropping trailing sections with only areadLines()“invalid input” warning. A spec whose sole non-ASCII characters were em-dashes in a comment lost enrollments 16–18, yielding a 15/18-enrollment grid with no error. The reader now loads the file as raw bytes (readBin, binary and locale-independent), strips an optional UTF-8 BOM, asserts the content is valid UTF-8 (validUTF8()— a clear “re-save as UTF-8” error otherwise), and parses that. BypassingreadLines()makes the read behave identically under any locale.
swereg 26.6.16
New Features
Intention-to-treat estimand (enrollment level).
TTEEnrollment$s4_prepare_for_analysis()gains anestimandargument ("pp", default, or"itt"). Withestimand = "itt", follow-up is no longer censored at treatment switching and IPCW is skipped — baseline IPW is the valid weight, so analyse with$irr(weight_col = "ipw_trunc"). The$irr()guard against IPW-only weights is relaxed for ITT-tagged datasets. Per-protocol behaviour is unchanged. The production pipeline (s2_generate_analysis_files_and_ipcw_pp()) now builds both a per-protocol and an intention-to-treat analysis file per ETT off the shared imputed file, ands3_analyze()computes an intention-to-treat IRR (irr_itt) and rates (rates_itt) per ETT alongside the per-protocol results. The exported workbook reports per-protocol and intention-to-treat side by side (a new “PP vs ITT” sheet) and adds a separate ITT forest plot. The TTE methodology and nomenclature vignettes now document both estimands (including a five-reasons censoring table) instead of stating ITT is unsupported.Effect modification (issue #6) — analysis methods.
TTEEnrollment$irr_by_subgroup(weight_col, subgroup_var)returns stratified IRRs (an"all"row plus one row per subgroup level) with the effect-modification p-value and (binary) ratio of stratum IRRs attached as attributes.$effect_modification_test(weight_col, subgroup_var)fits a singletreatment * factor(subgroup_var)model and runs the interaction Wald test — the correct test for whether stratum IRRs differ — returningratio_of_irrs = exp(beta)for a binary subgroup. Theirr()estimation core was factored into a shared internal helper;irr()output is unchanged. Strata with no events or one treatment arm degrade to NA with a warning. A top-level YAMLsubgroups:spec block is now parsed (tteplan_read_spec), validated (tteplan_validate_specrequires each subgroup variable to exist in the skeleton and be a confounder), and threaded intoTTEDesign$subgroup_vars.s3_analyze()now runs the stratified IRRs and the interaction test automatically per ETT for each subgroup variable and BOTH estimands (stored assubgroup_<var>_pp/subgroup_<var>_ittandemtest_<var>_pp/emtest_<var>_itt), and the exported workbook gains an “Effect modification” sheet showing the stratified IRRs (PP and ITT side by side) and the interaction test per ETT × subgroup.vignette("tte-workflow")gains an effect-modification example. This completes issue #6.
Bug Fixes
- ITT no longer requires a treatment-switch variable:
s5_prepare_outcome()previously stopped whentime_treatment_varwasNULLbefore the ITT branch could bypass protocol-deviation handling, breaking ITT for studies with no observed switching variable. -
tteenrollment_rbind()now preserves theestimandtag (and errors on mixing estimands), so a combined ITT object keeps its tag and$irr()does not wrongly reject its valid IPW-only weight. - Fixed mis-positioned roxygen so
tteplan_apply_exclusions()andtteplan_apply_derived_confounders()are exported and documented again (their documentation had drifted onto the internal.tte_build_*helpers, which are now correctly internal). Restores the pkgdown reference index.
Validation
- Added a simulation harness with a known true ITT effect (deliberately attenuated vs the true PP effect) that confirms
$irr()recovers the marginal ITT truth. The simulated truths are computed as first-event incidence rates (censoring each person at their first event, with a person-time-at-risk denominator) to match swereg’s estimand exactly, rather than as recurrent-event rates over a fixed denominator. Cross-checks againstTrialEmulationconvert its odds ratio toward the rate-ratio scale (Zhang & Yu 1998) and compare point estimates and CI widths; the conversion removes the scale difference only (not conditional-vs-marginal non-collapsibility), so the cross-package check is a bounded consistency check and the primary anchor is agreement with the known truth.TrialEmulationis itself validated against the known truth, not trusted as an oracle. Regression tests cover ITT without a switch variable, ITT retaining post-switch follow-up, ITT under independent loss to follow-up, and multi-seed CI calibration. - Added a swereg-vs-
TrialEmulationvalidation matrix (test-tte_validation_matrix.R): three escalating scenarios (no confounding / no loss; confounding + independent loss; confounding + informative loss), each checked across the full triangle (known truth, swereg, TrialEmulation) for both estimands, comparing point estimate and CI width. It shows swereg and TrialEmulation are identical with no confounder, both recover truth under confounding + independent loss, and – the key case – under informative loss the ITT estimand is biased in both packages (they agree with each other but miss the truth), while per-protocol IPCW stays close.TrialEmulationis inSuggestsand installed in the check workflow, so this runs in CI. Replaces the narrower ITT-only cross-package test.
Documentation
-
vignette("tte-methods")gains a “Marginal versus conditional estimands” section explaining why swereg’s IPW-based marginal IRR andTrialEmulation’s covariate-adjusted conditional OR differ (OR non-collapsibility) and how the validation reconciles them. -
vignette("tte-methods")’s validation section now documents the three-scenario swereg-vs-TrialEmulationmatrix and the Monte Carlo coverage results, and points to the executable tests (test-tte_validation_matrix.R,test-tte_coverage.R) as the enforced contract – replacing the prior generic paragraph.
swereg 26.6.8
Breaking changes
-
Cancer / ICD-O matchers consolidated.
add_icdo3s(),add_snomed3s()andadd_snomedo10s()are removed. Cancer ascertainment now goes through the newadd_cancer_without_morphology().-
add_cancer_without_morphology()matches cancer by topography (tumour site, C-codes), searching BOTHicdo10(ICD-O/2 topography, complete back to register start) andicdo3(ICD-O/3 topography, ~2000 onward). It supersedesadd_icdo3s(), which searched only the partialicdo3column. - Rationale: ICD-O topography codes ARE the ICD-10 neoplasm site codes (e.g.
C50= breast).icdo10is Socialstyrelsen’s confusingly-named column for ICD-O/2 topography – not “ICD-O edition 10”. A prior removal oficdo10from the diagnosis matcher silently dropped cancer-register ascertainment in callers that relied on it; this restores it under a clear name. - Morphology/histology (
snomed3/snomedo10) matching is dropped for now; it will return via a futureadd_cancer_with_morphology()when needed. -
Migration: replace
add_icdo3s(skeleton, data, id, icdo3s = <codes>)withadd_cancer_without_morphology(skeleton, data, id, codes = <codes>).
-
swereg 26.6.3
Per-stage worker counts; box-wide SWEREG_N_WORKERS retired
-
default_n_workers()now takes astagetag and defaults to 1 worker everywhere. Parallelism is opt-in, per stage, viaSWEREG_N_WORKERS_<STAGE>(e.g.SWEREG_N_WORKERS_S1=3) or the equivalentoptions(swereg.n_workers.<stage> = ). Each pipeline step passes its own tag:save_rawbatch()->"rawbatch",process_skeletons()->"skeleton",s1_generate_enrollments_and_ipw()->"s1",s3_analyze()->"s3".s2_generate_analysis_files_and_ipcw_pp()stays hardcoded1L. - The former box-wide
SWEREG_N_WORKERSenv var (andgetOption("swereg.n_workers")) are retired — a single global knob could silently leak a high worker count into a memory-heavy stage. Concretely, a host-wideSWEREG_N_WORKERS=3forced 3 x ~20 GB s3 IRR workers and OOM-killed the analysis loop on the biggest “vs none” panels (17M rows). The default is now safe (1) and heavy stages can never inherit a setting meant for a light one. A one-time warning fires if the deprecatedSWEREG_N_WORKERSis still set.
swereg 26.5.31
qs2 single-threaded in parallel_pool() workers (fix TBB segfault)
-
parallel_pool()workers (stages s1/s2/s3) now call qs2 withnthreads = 1Linstead offloor(detectCores() / n_workers). Multithreaded qs2 uses RcppParallel/TBB, whose scheduler segfaults nondeterministically while building its worker-thread pool (generic_scheduler::allocate_task, fault address0xff…f7) on some hosts — the cause of the s1 worker crashes that drifted across batches (1160/1398/1652/1792). Process-level parallelism viaparallel_poolalready provides throughput, so per-worker qs2 threading was redundant. qs2 stays multithreaded in the main process and the lower-exposure regen path (skeleton$save,load_rawbatch,save_rawbatchserial); data.table (OpenMP) is unaffected. See GitHub #5 anddev/debug_worker_gdb/for the gdb-based diagnosis.
swereg 26.5.29
Unified cohort-derivation flow (single source of truth)
- New internal
.build_cohort_flow()assembles one ordered participant-flow table per enrollment (eligibility exclusions + matching + per-protocol analysis dataset), each step tagged with akind(start/exclusion/selection/analysis) and a correctly-labelled per-stepchange_kind(excluded / not selected (matching) / censored (per-protocol)). The CONSORT diagram (.build_consort_dot) and the attrition worksheet (.write_attrition_sheet) now both render from this one table, so the diagram, the sheet, and the baseline-table denominators can no longer drift apart. - CONSORT diagrams now render a terminal “Analysis dataset (per-protocol)” box after “Enrolled after matching” (post-matching person-trials that contributed to the analysis,
n_baseline), styled distinctly (non-red): first-period censoring (protocol deviation or loss to follow-up) is analytic censoring handled by IPCW, not an eligibility exclusion. Matching is likewise shown as a selection step, not an exclusion. -
.s3_enrollment_worker()now records per-arm analysis-set counts (n_baseline_intervention/n_baseline_comparator), so the analysis box shows the same per-arm breakdown as the other boxes (falling back to the total when the split cannot be reconciled). Regenerating Loop 3a results is required to populate the per-arm counts on previously cached runs. - The attrition worksheet now includes the matching and analysis steps (it previously stopped at the eligible cohort) and gains
kind/change_kindcolumns; renamed to “cohort derivation (CONSORT)”. - Fixed
.format_enrollment_summary(): it previously labelledn_baseline(the per-protocol analysis-set size) as the “After matching” count. The summary now reports the true post-matching count from the matching table and labelsn_baselineas the analysis dataset. - Added
tests/testthat/test-cohort_flow.Rcovering flow assembly and DOT rendering of the matching and analysis steps.
Table 1: honest head-count N + separate sum-of-weights row
- The Table 1 top rows now distinguish counts from weights. Every panel (weighted or not) shows an
Nrow with the real head-count of contributing person-trials; weighted panels additionally show aSum of weightsrow (the effective weighted total). Previously the weightednrow reported the sum of weights labelled as a count. - Weighted panels now show the weighted proportion only in category cells (e.g.
9.7%) instead of a summed-weight pseudo-count with a percentage (e.g.53,615 (9.7%)). Unweighted panels are unchanged (count (pct%)), and weighted continuous rows keepmean (SD). - Percentage denominators are unchanged (still the weighted totals), so all proportions and SMDs are identical to before – only the displayed counts changed.
swereg 26.5.20
All-subprocess s1 architecture (OOM fix + clean dispatcher)
Background: the previous s1 design mixed parallel-pool work and main- thread work in the same loop. After parallel_pool() returned for the multi-scout, the main R process held ~41,686 (tuples, attrition) data.tables (2,194 skeletons x 19 enrollments) in RAM, then layered an rbindlist of ~2,194 panel chunks on top during the per-enrollment post-step. On a 19-enrollment run (2,194 skeleton files, 6 workers) this peaked high enough that the parent process either OOMed at the end of the multi-scout or starved the loop-3 workers when they spawned.
$s1_generate_enrollments_and_ipw() is now a pure dispatcher: every step that touches multiple skeletons’ worth of data runs in a subprocess and exits when done. The main R process holds only paths, status flags, and progressors – never a data.table.
Sub-step nomenclature
Loop 1 is split into four named sub-steps (s1a..s1d). Each sub-step runs in its own subprocess and communicates with the next via files in a per-project work directory:
{study$data_meta_dir}/s1_work/{project_prefix}/
| Sub-step | Mode | Worker script |
|---|---|---|
| s1a | parallel x skeleton (n_workers) |
worker_s1a_multi.R |
| s1b | single subprocess per enrollment | worker_s1b.R |
| s1c | parallel x (enrollment x skeleton) | worker_s1c.R |
| s1d | single subprocess per enrollment | worker_s1d.R |
s1a writes s1a_cache_* + s1a_pre_* + a per-skeleton sentinel. s1b reads all s1a_pre_* for one enrollment, samples comparators, writes s1b_enrolled_ids_* + s1b_attrition_* + the enrollment counts sidecar + sentinel. s1c reads s1a_cache_* + s1b_enrolled_ids_*, builds the panel, writes s1c_panel_* + sentinel. s1d reads all s1c_panel_* for one enrollment, imputes, computes IPW, truncates, writes the final file_raw + file_imp + sentinel.
The work directory is removed automatically on a successful end-to- end run.
Renames (breaking for internal ::: callers; no public API impact)
-
.s1b_worker()is now.s1c_worker()(panel build). The two-arg in-memory helper used bydev/verify_*.Randdev/profile_*.Ris exposed as.s1c_worker_impl()(formerly the body of.s1b_worker). -
inst/worker_s1b.Rpreviously dispatched panel build; that role moved toinst/worker_s1c.R.inst/worker_s1b.Ris now the match worker. -
.s1a_worker()(single-enrollment scout) is unchanged. Used only by tests/verify/profile; the orchestrator does not call it. - New internal helpers:
.s1b_worker(),.s1d_worker(),.s1_work_dir(), and path constructors (.s1a_cache_path(),.s1a_pre_path(),.s1a_done_path(),.s1b_enrolled_ids_path(),.s1b_attrition_path(),.s1b_done_path(),.s1c_panel_path(),.s1c_done_path(),.s1d_done_path()), and.touch_sentinel().
Resume
resume = TRUE is now sentinel-based across all four sub-steps. The master skips any sub-step whose sentinel file is present in the work directory, so a crash in s1c (for example) only requires redoing the missing panel chunks – not the upstream scout or the downstream post-step.
parallel_pool(collect = FALSE) everywhere
All four sub-steps invoke parallel_pool() with collect = FALSE. Workers write their outputs directly to final paths in the work directory; no result data is shipped back to the master through qs2 tempfiles. This was the architectural change that eliminated the post-pool memory hump.
What didn’t change (by design)
- Math/semantics are identical – the match step uses the same
set.seed(enrollment_spec$seed)and the samedata.tablegroup-by- sample logic; the post step uses the same
tteenrollment_rbind -
s2_ipw+s3_truncate_weightschain; IDs inenrolled_ids,enrollment_counts,file_raw, andfile_impare bit-identical to those produced by 26.5.19.
- sample logic; the post step uses the same
-
swereg::tteplan_locate_and_load(...)$s1_generate_enrollments_and_ipw(...)– the user-facing entry point – has the same signature (output_dir,impute_fn,stabilize,n_workers,swereg_dev_path,resume).
Caveats / behaviour change to be aware of
-
impute_fnis serialised via qs2 across the subprocess boundary into s1d. The defaulttteenrollment_impute_confoundersis namespaced and round-trips cleanly. Custom imputation closures that capture unexported state from the caller’s session may not deserialise; pass them as eitherswereg::your_fn-style refs or as self-contained closures. - The work directory consumes ~10-20 GB during a run of that size (cache + pre + panel chunks). Plan accordingly; on success it is removed automatically.
swereg 26.5.19
Performance
Large-scale flame-graph-driven optimisation of s1. End-to-end output is bit-identical (verified via A/B against the pre-patch reference on a real production skeleton: 25 columns x 4.16 M panel rows, plus direct comparison of (tuples, attrition) for all 19 enrollments on the first skeleton file). On a study of that size (19 enrollments x 2,194 skeleton files, 6 workers) the projected wall savings on a 10-day s1 run total ~21 hours.
Stage 1a / multi-enrollment scout
s1_generate_enrollments_and_ipw()now does its scout pass per skeleton file across all enrollments at once, instead of per (enrollment x skeleton). Each canonical skeleton (~5 MB qs2, ~3.7 GB decompressed for 1,025 columns) is deserialised ONCE per s1 run instead of 19 times, saving ~9-11 hours wall on a run of that size. Driven by a new internal worker.s1a_worker_multi()and worker scriptinst/worker_s1a_multi.R.The multi-scout worker projects the canonical to the union of columns any enrollment uses (typically ~50-100 of ~1,025) immediately after load, dropping the rest in place via
:= NULL. Apply-exclusions etc. then operate on a much smaller working data.table. Between enrollment iterations we drop only the columns prepare/finalize added (instead ofdata.table::copy()-ing the canonical, which itself cost ~3 s per iteration). New helper:.tte_canonical_needed_cols()..s1a_worker_multi()writes a per-skeleton scout checkpoint file (s1_scout_<basename>.qs2, ~0.5 MB) containing all 19 enrollments’(tuples, attrition). The outer dispatch checks for existing checkpoints + cache files and skips any skeleton whose scout is already complete, so a mid-scout crash on resume only redoes the skeletons that hadn’t finished. Checkpoint round-trip: 72 s scout vs 0.08 s read.Split
.s1_prepare_skeleton()into.s1_load_skeleton()(qs2 read +setalloccol+setkey) and.s1_prepare_loaded()(exclusions + treatment + eligibility combine) so the two worker variants share internal logic.Split
.s1a_worker()’s post-prep work into.s1a_finalize_on_skeleton()(attrition + tuples + cache write), shared with.s1a_worker_multi().
Stage 1b / cache projection
.s1a_worker()and.s1a_worker_multi()write the per-enrollment cache projected to only the columns s1b actually consumes (~30 of ~1,025):id,isoyearweek,trial_id, treatment/rd_intervention cols,design$confounder_vars,design$outcome_vars, theeligible_*cols, plus source variables for any computed confounder. Cache file shrinks ~10x (~5 MB -> ~0.5 MB per file); s1b cache read drops from ~5 s to ~0.5 s per worker call. Across 19 x 2,194 s1b calls / 6 workers this saves ~10 hours wall. New helper:.tte_s1_cache_columns().Removed the redundant per-enrollment
%in%filter inprivate$enroll()Phase B when.s1b_workerhas already filtered the cache to enrolled persons upstream (gated by the new.tte_filtered_to_enrolledattribute on the skeleton). Avoids allocating a ~3 GB identity copy of the panel per stage-1b worker.
Batched per-id derivations (s1a)
-
tteplan_apply_exclusions()andtteplan_apply_derived_confounders()now collect all per-person (by = id) grouped derivations into a singledt[, c(...) := list(...), by = id]call via the new.tte_apply_eligibility_batch()helper, instead of onedt[, col := f(x), by = id]call per criterion. With 12 exclusions- 4 computed confounders for 003, this collapses 16 separate radix walks of the 17 M-row skeleton into 1.
.s1_prepare_skeleton()fuses both helpers into one combined batch so the skeleton is walked exactly once.
- 4 computed confounders for 003, this collapses 16 separate radix walks of the 17 M-row skeleton into 1.
.s1_compute_attrition(): fusedfiltered <- sk[mask]; pt_i <- filtered[, ..., by]intopt_i <- sk[mask, ..., by]inside the cumulative-criterion loop, eliminating ~220 MB allocation per criterion. Dropped redundant== TRUEon the cumulative mask; replacedsum(.tte_tx_any == TRUE)/sum(.tte_tx_any == FALSE)withsum(.tte_tx_any)/sum(!.tte_tx_any)..s1_eligible_tuples(): fused[i][, j, by=]and dropped the redundantsetorderv()(caller already sorted;any()doesn’t need ordered input). Eliminates a 1.18 GB intermediate allocation..s1_prepare_skeleton(): collapsed three sequential[, := ]calls forrd_intervention/baseline_intervention/eligible_valid_treatmentinto one multi-column assignment (evaluatesfcase()once and writes three columns in one dispatch).any_events_prior_to(): replacedc(FALSE, cum[-n] > 0L)(three n-vector allocations per call – slice, compare, prepend) withdata.table::shift(prior_counts, n = 1L, fill = 0L) > 0L(one allocation). The function is called once per person per exclusion criterion, so the saving multiplies.
Faster post-rbind impute + IPW (s1 post)
-
TTEEnrollment$s1_impute_confounders()is faster on production-scale panels. Three changes:- Pre-scan baseline rows for NAs and skip the full panel collapse + merge entirely when every confounder is complete.
- When only some confounders need imputing, restrict the group-by collapse and merge-back to that subset (
needs_impute) instead of the fullconfounder_vars. - Replaced the drop +
merge.data.tableround trip with an[, := mget(paste0("i.", needs_impute)), on = id_var]update join. Avoids allocating a new merged data.table for the 17 M-row panel. Measured on a 17 M-row trial panel with 7 confounders: impute step dropped from 4.69 s to 4.17 s; full post-rbind block (impute + s2_ipw + s3_truncate_weights) 10.19 s -> 8.86 s.
private$enroll()Phase B: replaceddata[get(person_id_col) %in% enrolled_person_ids]with a binary- search keyed joindata[.(unique(...)), on = person_id_col]on the existing(id, isoyearweek)key. Avoids the temporary hash allocation that drove GC pressure on this 17M-row filter.private$enroll()Phase B: dropped thesetorderv()immediately precedingsetkeyv()on the same columns (two sorts collapsed into one). Includedisoyearweekin the key sofirst(isoyearweek)inside the aggregation remains deterministic.
Misc
Replaced
paste0()withstringi::stri_c()at the three hot panel-ID construction sites (r6_tteenrollment.RPhase B + Phase C,enrollment_idprefix inr6_tteplan.R::.s1a_worker).stri_c()is ~50% faster thanpaste0()on long character vectors. Stage-1b paste0 self time dropped from 9.2% to 4.7%.Added
stringitoImports.
Progress reporting
- The single combined
progressr::progressor()is replaced by four per-loop progressors, each created lazily right before its loop so the handler’s “active” bar matches the current phase:-
p_eligibility(2194 steps, per skeleton, parallel) for the multi-enrollment scout. -
p_match(19 steps, per enrollment, main process) for the comparator-sampling step. -
p_panel(19 x 2194 steps, per enrollment x per skeleton, parallel) forworker_s1b.R. -
p_post(19 steps, per enrollment, main process) for rbind + impute + IPW + truncate + save. Each loop is preceded by acat()header explaining what work it does and over what unit.
-
swereg 26.5.18
Bug Fixes
-
TTE per-protocol bias fix:
TTEEnrollment$s4_prepare_for_analysis()now drops censoring-event rows (wherecensor_this_period == 1) fromself$dataafter the IPCW model is fit. Previously these rows were retained withevent = 0, which biased the downstream weighted outcome regression toward the null.On a synthetic dataset with a known true per-protocol log-OR of -0.49, the previous behavior produced an estimate of -0.38 (bias +0.11); after the fix, the estimate is -0.52 (bias -0.02), agreeing closely with the canonical CRAN
TrialEmulationpackage on the same data.
New Tests
-
tests/testthat/test-tte_simulation_correctness.R: end-to-end correctness tests using simulated data with a known true PP effect. Skipped on CRAN. -
tests/testthat/test-tte_vs_trialemulation.R: cross-package comparison against the CRANTrialEmulationpackage. Skipped whenTrialEmulationis not installed or on CRAN.
swereg 26.5.17
Breaking changes
-
RegistryStudy$compute_population()andRegistryStudy$compute_summary()are no longer public methods. They are now internal and run automatically at the end of$process_skeletons(). - Population computation is declarative: pass
population_by_specstoRegistryStudy$new()(a list of character vectors, one per desiredbyaggregation). Each registered spec is pre-computed into the per-batchmeta_*.qs2sidecar and reduced topopulation_<spec>.qs2once perprocess_skeletons()run. - New getter
study$population(by = c(...))reads the cached population table. Errors with a clear message whenbyis not inpopulation_by_specs. - New active binding
study$summaryreads backsummary.qs2.
Performance
-
study$population(by = ...)is now a sub-second meta-only walk instead of re-loading every full skeleton from disk on each call. First call afterprocess_skeletons()is fast because the per-batch aggregations were computed in-memory while skeletons were already loaded. - Adding a new spec to
population_by_specsbetween runs triggers a meta-only refresh on the next$process_skeletons(): skeletons on disk are not rewritten; only themeta_*.qs2sidecars are augmented with the missing aggregation.
Changed
-
$delete_skeletons()now also removes cachedpopulation_*.qs2andsummary.qs2files indata_skeleton_dir.
Fixed
Excel
excel_spec_summary()renderer: named criteria under each enrollment’sadditional_inclusion/additional_exclusionblocks now render one indent deeper than their parent section header (previously they collided with it), and theAge range:row is bolded like the other criterion names. Adds a third indent level (sub-sub) with new stylesst_*_sub_sub_item/st_*_sub_sub_label, a newadd_sub_sub_item()helper, and asub_subargument onadd_kv()/add_yellow()/add_var()/add_derived_var().Excel
excel_spec_summary()renderer: surfacesspec$standing_methods$calendar_timeas the first entry in the Confounders section when present. Calendar time at trial registration is auto-adjusted via the IPW/IPCW models, so rendering it explicitly stops protocol reviewers re-asking “what about calendar year?” on every TTE.
Changed
-
self$spec_xlsx(and thereforeexcel_spec_summary()when called without an explicitpath) now writes tospec_<version>.xlsxinstead of a fixedspec.xlsx. The filename mirrors the YAML convention (spec_v003.yaml,spec_v004.yaml, …) so each spec iteration produces a non-overwriting Excel artefact alongside it. The previousFILENAME_SPEC_XLSXconstant is replaced byfilename_spec_xlsx(version).
swereg 26.5.15
Changed
-
status.txt: each section (never-matched, rare) now leads with a bucket-count summary (one row per registry prefix, descending by size) before the per-bucket detail blocks. Per-bucket lists are never collapsed – every column name appears in full. Rare cutoff rendered as “1-9” (clearer than “< 10”).
swereg 26.5.14
Changed
-
status.txtrendered by$compute_summary()is restructured:- The
[ok]count appears first (above the noise) so the headline number (“how many variables look healthy”) is immediately readable. - Never-matched and rare-variable sections are grouped by the column’s registry-type prefix (everything up to the first underscore:
dorsm,sv,os,osd,can,op,rx, …), sorted by bucket size descending so the dominant problem shows first. Avoids the 252-line flat alphabetical list that previously forced the reader to scroll past the entire never-matched dump to reach the actually-OK count. - All numbers comma-formatted (
8,852,776not8852776). - Trailing pointer to
summary.qs2for full per-column detail.
- The
swereg 26.5.13
Changed
- Meta sidecar now carries weekly/annual splits and the date range of weekly data:
n_rows_weekly/n_rows_annual,n_persons_weekly/n_persons_annual,weekly_min_isoyearweek/weekly_max_isoyearweek,annual_min_isoyear/annual_max_isoyear. Per-column$countsnow emitn_person_weeks_with(TRUE rows whereis_isoyear == FALSE) andn_person_years_with(TRUE rows whereis_isoyear == TRUE) separately; previously the single field was misleadingly named since it was actuallynrow()(weekly + annual combined). -
$compute_summary()aggregates these splits;status.txtnow prints the WEEKLY and ANNUAL time periods with their respective denominators. - TSV audit-track gains an
n_person_years_withcolumn and header comments for both periods.
swereg 26.5.12
Breaking
- Removed the per-batch code-check warning machinery added in PR #4 + 26.5.10. Specifically removed:
start_code_check_session()/end_code_check_session()exports,warn_unmatched_codes()/warn_empty_logical_cols()exports, the internal.swereg_codes_pre()/.swereg_codes_post()hooks,.code_check_snapshot()/.code_check_merge()/.code_check_emit(), and thecode_check_statefield on the meta sidecar. Rationale: this information is now exposed more usefully viaRegistryStudy$compute_summary()(see below), so having two parallel mechanisms reporting the same data was redundant. -
RegistryStudy$save_skeleton()no longer takes acode_check_stateargument (it had no other callers). The meta sidecar shape changed:code_check_stateis dropped,n_personsis added, and every entry inapplied_registrynow carries a$countssub-field. Older meta sidecars (built before this version) keep working but their$countsfield is missing, so$compute_summary()will report zero per-column counts for those batches. To fix, delete the affectedskeleton_*.qs2+meta_*.qs2files (or callstudy$delete_skeletons()) and re-run$process_skeletons().
New
-
RegistryStudy$compute_summary(): aggregates per-batchmeta_*.qs2sidecars into a study-wide sanity report. Always writessummary.qs2(binary; programmatic reload) andstatus.txt(human-readable flag report) todata_skeleton_dir. On full runs (every expected batch present), also writes a git-trackedsummary_<UTC>_<git-sha>_<swereg-ver>.tsvto the newdata_summaries_dircandidate, with counts belowsuppress_below(default 5) masked as"<N"(Swedish registry data convention). Partial runs explicitly skip the TSV. - New
data_summaries_dirconstructor parameter (optional). Defaults to NULL; when NULL,$compute_summary()skips the TSV even on full runs. -
Skeleton$apply_code_entry()now computes per-columnn_persons_withandn_person_weeks_withfor every column it adds, stored on the entry’sapplied_registryrecord. These are the primitive that$compute_summary()rolls up. -
kept–expand_codes()/expand_code_list()(bracket / range expansion utilities from PR #4) remain. The expansion of code patterns happens inline in eachadd_*()function; the removed pieces were specifically the per-call warning emission machinery, not the code expansion machinery.
swereg 26.5.11
Fix
-
.swereg_dev_path()(helper that hands the package root to callr workers so they candevtools::load_all()the same dev source as the parent) was returningsystem.file(package="swereg")directly, which for a dev-loaded package is theinst/subdirectory – not the package root thatdevtools::load_all()expects. Workers then loaded a broken namespace and$process_skeletons()failed on batch 1 withattempt to apply non-function. Now strips the trailing/instso the worker receives the actual package root.
swereg 26.5.10
Breaking (on-disk)
- Skeleton schema bumped from version 4 to version 5 to introduce a
meta_%05d.qs2sidecar next to everyskeleton_%05d.qs2. Existing skeleton directories from earlier versions are not readable; runstudy$delete_skeletons()and re-run$process_skeletons()to regenerate. The sidecar is small (a few KB) and lets$process_skeletons()do an incremental-rebuild check by reading meta only – avoiding the full skeleton deserialise on the common no-change path. For a 2000-batch delivery with 500 MB skeletons this turns a ~1 TB read into a few MB.
New features
RegistryStudy$process_skeletons()now emits a single consolidated code-check warning at the end of every run, covering every batch in scope. Sequential and parallel runs behave identically because the per-batch accumulators flow through the meta sidecars on disk rather than via in-memory state.RegistryStudy$skeleton_pipeline_hashes()now reads the small meta sidecars instead of deserialising every skeleton, with a transparent fallback to loading the skeleton when meta is missing. Significantly faster on large studies.
Internal
- The code-check session machinery introduced in 26.5.9 is now fully internal: the previously-exported
start_code_check_session(),end_code_check_session(),expand_codes(),expand_code_list(),warn_unmatched_codes(),warn_empty_logical_cols()are no longer exported. Users running through$process_skeletons()get cross-batch aggregation automatically; users running manual loops outsideRegistryStudyget per-call warnings (the pre-26.5.9 behaviour). If you need cross-batch aggregation in a manual loop, open an issue and we’ll re-export.
swereg 26.5.9
New features
add_diagnoses(),add_operations(),add_cods(),add_rx(),add_icdo3s(),add_snomed3s()andadd_snomedo10s()now accept bracket / character-class / range patterns directly (e.g."I2[0-5]","FN[ABCDEGW][0-9][0-9]","!302[A-Z]"). Bracket expansion runs unconditionally; the matchers themselves continue to usestartsWith()on literal prefixes.The same
add_*family now runs pre-call (per-literal source-data) and post-call (column-level) sanity checks automatically. Bad patterns surface at run-time instead of producing silent empty columns. Both checks can be disabled viaoptions(swereg.check_codes = FALSE).The pre-call check also runs a cheap, data-free syntax check on the expanded code list, firing in milliseconds at the first
add_*()call rather than at hour 6 of a multi-hour batched pipeline. It warns when any expanded literal is empty or contains regex metacharacters (^ $ * + ? . ( ) | \ [ ]) that will not match understartsWith(). Skipped automatically foradd_rx(source = "produkt")because product names are exact-matched via%chin%and may legitimately contain those characters.
Contributed by @alexengberg (PR #4).
swereg 26.5.8
Breaking
- File-naming format width bumped from 3 digits to 5 digits for batch / ETT identifiers. Rawbatch files become
00001_rawbatch_lmed.qs2(was001_rawbatch_lmed.qs2); skeleton files becomeskeleton_00001.qs2(wasskeleton_001.qs2); ETT identifiers becomeETT00001(wasETT001). Existing on-disk files using the old 3-digit width will not be recognised by the new code – callers must rename them via shell or regenerate. AffectsRegistryStudy(rawbatch + skeleton) andTTEPlan(ett_id, and thefile_analysis = "<prefix>_analysis_<ett_id>.qs2"derived name).
swereg 26.5.7
Breaking-ish
-
registrystudy_load(): parameter renamedcandidate_dir_rawbatch->candidate_dir_meta. Callers must update if they passed by name. Behaviour is unchanged for callers that passed the same path used atRegistryStudy$new(data_meta_dir = ...)(which defaults to the rawbatch dir, so existing scripts continue to work positionally).
New
-
RegistryStudy$new()gainsdata_meta_dir: candidate paths for the directory holdingregistrystudy.qs2. Defaults todata_rawbatch_dir(full backward compatibility). Pass an explicit value – e.g. the parent of rawbatch – to keep the singleton control file out of the per-batch data directory. Exposed as a read-only active binding. -
$meta_filenow resolves tofile.path(self$data_meta_dir, "registrystudy.qs2").
swereg 26.5.6
Performance
-
create_skeleton()is now ~8x faster and uses ~7.5x less memory at every cohort size tested (1k–25k IDs over an 11-year range; linear scaling). The win comes from sorting a single time spine once and replicating it per id, instead ofexpand.grid()-ing the full cartesian product and then sorting the result. Output is identical to the previous implementation modulo a hiddendata.tablesecondary index attribute. Contributed by @gkaramanis (PR #3).
Internal
- New
dev/bench/scaffold for tracking performance regressions:Rscript dev/bench/run_all.Rruns all benchmarks against the current source,Rscript dev/bench/diff.Rcompares the run to a checked-inbaseline.csvand exits non-zero on >20% time / >50% memory regressions. Excluded from the package build via.Rbuildignore. - New
tests/testthat/test-create_skeleton-parity.R(31 tests) pinningcreate_skeleton()output against an embedded reference oracle plus structural invariants and edge cases (empty / duplicate / NA ids, single-day range, ISO W53 year, year boundary).
swereg 26.4.28
Breaking changes
-
RegistryStudy$process_skeletons()now fails fast on any batch error instead of swallowing it into awarning()and pushing through the remaining batches. These pipelines run unattended for days; if batch 1 fails 10 minutes in (e.g. a systematic bug, missing column, unreadable rawbatch file), the user wants to SSH in within minutes and see the failure – not at the end of a 4-day run with the remaining batches all failing for the same root cause. The failing batch’s underlying error is surfaced via astop()that includes (a) which batch number failed, (b) the original error message, and (c) a hint that successful batches are persisted on disk and the user can rerun withbatches = ...to retry from the failed one. In then_workers > 1(callr subprocess) path, in-flight workers are killed viakill_tree()before thestop()so they don’t keep burning compute on what is likely the same systematic failure. Callers who intentionally want the old “complete with warnings” behaviour can wrap the call intryCatch(error = function(e) ...).
New features
-
add_rx()now supports"!"-prefixed exclusion patterns, restoring parity with theadd_diagnoses()family. Previously,add_rx’s matcher was a one-shotReduce(|, ...)union with no per-pattern branching, so"!"-prefixed entries were silently treated as literal first characters of an ATC code (which never match) or literal product names (which would, accidentally, include a brand named with a leading!). Now:matches any lipid-modifying agent except the statin and fibrate (C10AA / C10AB) classes. This closes the spec-expressiveness gap that previously forced exhaustive enumeration of every desired sub-code. The veto is independent per named code (no leak across list entries) and applies whether
source = "atc"(prefix match) orsource = "produkt"(exact match).
Documentation
Expanded
add_rx()@param codesto spell out four nuances that theadd_diagnosesfamily carried implicitly: vetoes are independent per named code (no leak across list entries); veto match style followssource(prefix for atc, exact for produkt – so"!Sertralin"does NOT mask"Sertralin Sandoz"); all-negative pattern sets produce empty columns; and the per- source-row veto interacts with the per-week aggregation such that a non-vetoed Rx still drives a week to TRUE even if a vetoed Rx overlaps in the same week.Fixed misleading pattern-syntax documentation on
add_diagnoses(),add_cods(),add_icdo3s(),add_snomed3s(),add_snomedo10s(), andadd_operations(). The previous text described patterns as regex with auto-prepended^anchors and example strings like"^F640"/"^8140"/"^80146002". The actual matcher is prefix-only viastartsWith()– a literal^in a pattern is treated as an ordinary character and silently matches nothing. Updated each function’s@param codesto describe the real contract (prefix matching, no regex), and added a clear explanation of"!"-prefixed row-level vetoes (including the important detail that the veto operates on the raw source row, not on the(id, isoyearweek)bucket – a non-vetoed code in the same week still triggers TRUE).add_rx()documentation now explicitly notes that it does NOT support"!"exclusion patterns (its matcher is a simple union).
swereg 26.4.27
Maintenance
- Renamed user-facing “exposure” terminology in the results workbook to match the project’s TTE vocabulary, where the assignment is the treatment and the active arm is the intervention. The Sensitivity sheet’s identifier column header
Exposureis nowIntervention, and the per-arm measurement columnsEvents (exp),PY (exp),Rate/100k (exp)are nowEvents (int),PY (int),Rate/100k (int)(paired with the unchanged(cmp)columns). Existing workbooks already on disk are not rewritten – re-runplan$export_tables()to regenerate.
New features
TTEPlan$s3_analyze()gains aforceargument (defaultFALSE). WhenTRUE, cachedresults_enrollmentandresults_ettentries in the targeted scope are dropped before recomputation. Scope followsenrollment_ids/ett_ids: with bothNULL, all cached results are cleared; otherwise only matching entries are dropped. Provides a supported way to recompute after a broken environment producedskipped = TRUEplaceholders (e.g. missingsurveypackage), without poking R6 internals from the calling script.TTEPlan$s3_analyze()gains ann_workersargument (default1L). Both the enrollment loop and the per-ETT loop now dispatch throughparallel_pool()with the requested concurrency (previously hardcoded to1Lat both call sites). Each subprocess loads its own analysis file, so peak RAM scales linearly withn_workers; CPU threads per worker are auto-partitioned asfloor(detectCores() / n_workers).
Bug Fixes
CONSORT cascade no longer double-counts the global cohort. In
.s1_compute_attrition(), the per-trial summary tables (before_rowand the per-criterionrows[[i]]) were grouped bytrial_idover apt0that included person-weeks outside any trial period. Those rows collapsed into a spurious(trial_id = NA, criterion)group whosen_personslater got summed together with the legitimatebefore_global/global_rows[[i]]row during per-batch aggregation (by = .(trial_id, criterion)at the centralized matching step), roughly doubling the global cohort number in the CONSORT diagram and attrition tables. Per-trial counts and arm counts were unaffected. Fix filterspt0[!is.na(trial_id)](andpt_i[!is.na(trial_id)]) before computing per-trial summaries; the global row is still computed off the unfilteredpt0so it captures the full pre-exclusion uniqueN. Existingenrollment_counts_*.qs2files need to be regenerated by re-running s1 to pick up the corrected counts.inst/worker_s2.Rnow passessep_by_tx(matching.s2_worker()and the plan-side item field) instead of the stalesep_by_exp. Previouslyplan$s2_generate_analysis_files_and_ipcw_pp()failed at the very first ETT withunused argument (sep_by_exp = params$sep_by_exp).
Maintenance
- Promoted runtime-required packages from
SuggeststoImportsso they install automatically:survey,survival,mgcv,MASS,scales,glue,openxlsx,patchwork,DiagrammeR,DiagrammeRsvg,rsvg. All are referenced unconditionally from R/ (norequireNamespace()guards or alternative code paths). Previously, missingsurveycaused every IRR call ins3_analyzeto be silently captured aslist(skipped = TRUE, reason = "there is no package called 'survey'"), which then produced an empty forest plot inexport_tableswith no visible error. MissingDiagrammeR/DiagrammeRsvg/rsvgsimilarly skipped CONSORT sidecars with only a warning.
swereg 26.4.22
New features
-
CONSORT reporting surfaces unique-person counts alongside person-trial counts. Sequential TTE inflates the analytic denominator because one person enters many weekly trials; a cohort with 390k women can generate 22M person-weeks, and the 60x gap routinely confuses reviewers. Attrition bookkeeping now carries both numbers end-to-end:
-
.s1_compute_attrition()emits a globaltrial_id = NArow per criterion alongside the existing per-trial rows, with a trueuniqueN(person_id)across the whole skeleton. Summing per-trialn_personsover-counted anyone entering more than one trial; the global row is the honest number. Per-trial rows are retained for diagnostic slicing. -
.build_consort_dot()now renders one lumped red side-box with a bulleted list of every exclusion criterion (CONSORT-2010 convention) instead of a stacked red box per criterion, and each box reportsN persons / M person-trials. Enrollment titles are split at ” (” onto two lines so long spec labels don’t blow out the box width. -
TTEEnrollment$rates()gains ann_personscolumn per treatment arm, usingdesign$person_id_var. - Results workbook: each enrollment’s combined-baseline sheet opens with a one-line “Cohort: N persons contributed M sequential trial enrollments” summary. A companion
Attrition_{id}sheet carries the tabular form of the CONSORT numbers (criterion / n_persons / n_person_trials / excluded counts / n_intervention / n_comparator) so reviewers can cite exact figures without measuring pixels on the PNG sidecar.
-
-
$register_codes()auto-validates theadd_*contract. Every call to a registeredfnis now wrapped with a pre/post-state check: row count must be preserved, structural columns (id,isoyear,isoyearweek,is_isoyear) must still be present, and every column name in the registration’scodeslist must actually exist on the skeleton after the call. Contract violations error loudly with a pointer back to the offending$register_codes(<label>)entry. Customadd_*functions plugged into the pipeline (Norwegian registries, regional Swedish cohorts, payer claims, …) get this enforcement for free; built-ins already pass the checks, so no behaviour change for existing registrations.
Documentation
-
New vignette
builtin-add-functions— end-to-end walkthrough of everyadd_*function swereg ships (add_onetime,add_annual,add_diagnoses,add_operations,add_rx,add_cods,add_quality_registry), with pattern syntax, collision policies, and a typical end-to-end ordering. -
New vignette
custom-add-functions— how to write your ownadd_*function for registries swereg doesn’t ship support for (non-Swedish registries, in-house data, quality registries without dedicated built-ins). Covers the contract, reusing built-ins via$register_codes(fn = swereg::add_diagnoses),fn_argsfor extra knobs likediag_type = "main", a completeadd_vaccinations()worked example run through a realRegistryStudy, a demonstration of the auto-wrap firing on a deliberately broken function, and a design cheat sheet of lessons from the built-ins.
User experience
-
RegistryStudy$skeleton_pipeline_hashes()now reports progress. After the last batch finishes,$process_skeletons()calls$write_pipeline_snapshot(), which in turn calls$skeleton_pipeline_hashes()to collect per-batch metadata. That function has to deserialize everyskeleton_*.qs2file from disk (viaqs2::qs_read()) to read a handful of hash fields, which with many/large batches can take tens of minutes. Previously this ran silently, making$process_skeletons()look hung after dispatching the last batch. It now prints an explanatory message up front and ticks aprogressrbar per file.
swereg 26.4.16
Documentation
- Consolidated hand-rolled vignettes from 3-step (skeleton1-create, skeleton2-clean, skeleton3-analyze) into a 2-step manual workflow (skeleton-create, skeleton-analyze) that matches the actual architecture.
- Renamed pkgdown “Hand-rolled” section to “Manual workflow”.
- Updated skeleton-concept, skeleton-pipeline, and CLAUDE.md to reflect the 2-step model.
swereg 26.4.20
Bug fixes
add_cods()now acceptscodes =like all siblingadd_*functions (previouslycods =). The oldcods =name is kept as a deprecated alias with a warning. This mismatch caused everyadd_codsentry registered viaRegistryStudy$register_codes()to fail silently with"unused argument (codes = list(...))"— the dispatcher always passescodes = ...butadd_codswas the only one not expecting that name.RegistryStudy$process_skeletons()progress bar now advances when batches fail. Previously, the parallel branch’s error handler emitted a warning but never ticked the progressor, so a single failing batch left the bar frozen and — combined withoptions("warn")default of0buffering warnings until the function returns — produced the symptom “progress bar stuck at 0% indefinitely”. The bar now ticks on both success and failure, and failed-batch messages show up in the bar’s(last: ...)slot with aFAILEDtag. Warnings are also emitted withimmediate. = TRUEso failures surface in real time regardless of the session’swarnsetting. The serial branch gained matching error handling (previously a single batch error aborted the whole run).
BREAKING CHANGES
-
TTE vocabulary rename (
exposure/exposed/unexposed→treatment/intervention/comparator): the TTE system now uses PICO-aligned terminology.treatmentis the umbrella concept (the variable naming the assignment);interventionandcomparatorare the two arm values. This matches how TTE papers are written up in the major medical journals (BMJ, NEJM) and Hernán’s canonical target-trial references. Migration:YAML spec:
-
exposure:→treatment: -
exposed_value:→intervention_value: -
arms.exposed:→arms.intervention:
TTEDesign$new()arguments:-
exposure_var = ...→treatment_var = ... -
time_exposure_var = ...→time_treatment_var = ...
Skeleton / trial-panel columns produced by the pipeline:
-
baseline_exposed→baseline_intervention -
rd_exposed→rd_intervention -
n_exposed/n_unexposed(in CONSORT / matching summaries) →n_intervention/n_comparator(and the_total/_enrolledvariants)
Logical semantics unchanged:
TRUE= intervention arm,FALSE= comparator arm. Column names now describe whatTRUEmeans, which is clearer than the prior “exposed” (ambiguous between an assignment and a time-varying status). User-chosen column names (e.g., a column named"exposed"in user data) are unaffected — the rename only touches package-provided API surface. -
New features
-
RegistryStudy$register_derived_codes(codes, from, as): registers a “derived” code entry that doesn’t read rawbatch data, but instead ORs together already-existing skeleton columns from earlier primary entries. For eachnmincodes, writes<as>_<nm> := <from[1]>_<nm> | <from[2]>_<nm> | .... Use case: building combined outcome columns likeosd_* = os_* | dorsu_* | dorsm_*where the hospital half comes fromadd_diagnoseson OV+SV and the death half comes fromadd_codson DORS – two functions that can’t share a singlecombine_asargument because they search different raw-data columns (hdia/dia*vsulorsak/morsak*).Before this release, a registration with
groups = list(ov, sv, dors)andcombine_as = "osd"ranadd_diagnoses()on DORS data. DORS has none of the code columns thatadd_diagnoses()searches, so everydors_*column wasFALSEand deaths never contributed toosd_*.Derived entries are full first-class citizens of the code registry: they get their own fingerprint, participate in the per-entry incremental sync via
Skeleton$sync_with_registry(), and fold upstream primary fingerprints into their own so that editing an upstream primary’sfn_args/groups/codes(e.g. flippingcod_typefrom"underlying"to"multiple") automatically cascades into a derived replay. Derived entries must be registered AFTER their upstream primaries (apply runs in registration order).
swereg 26.4.19
BREAKING CHANGES
New
SkeletonR6 class: per-batch skeleton files on disk are now serializedSkeletonR6 objects instead of baredata.tables. They carry their own phase provenance (framework hash, applied code registry fingerprints keyed by their minimal descriptor, and an ordered named list of applied phase-3 “randvars” steps). Legacy bare-data.tableskeleton files are auto-wrapped on first load for backwards compatibility. Downstream code that reads skeletons viaqs2_read()now needs to unwrap viask$data; the three swereg internal call sites (in.s1_prepare_skeleton(),.s1b_worker()cache reuse, andtteplan_from_spec_and_registrystudy()) have been updated.-
RegistryStudy$process_skeletons()signature changed: drops theprocess_fncallback argument. Callers must pre-register their pipeline functions via$register_framework(fn)and$register_randvars(name, fn)before calling$process_skeletons(). The new three-phase orchestration re-runs each phase only when its relevant part of the pipeline has changed:- Phase 1 (framework): full rebuild on function body/formals hash change.
- Phase 3 (randvars): ordered per-step divergence-point rewind-and- replay. Editing one step replays that step and everything downstream of it; upstream steps untouched.
- Phase 2 (codes): per-entry fingerprint diff. Adding/removing one code entry touches only that entry’s columns on existing skeletons. For the typical “edit one ICD-10 code” workflow, this drops the re-run cost from a full pipeline rebuild to roughly one-Nth of phase 2, where N is the number of registered code entries.
RegistryStudyschema bump (3 → 4): existingregistrystudy.qs2files with an older schema error on load via$check_version()with an actionable message pointing at re-running the upstream generator script.
New features
SkeletonR6 class (R/r6_skeleton.R). Public methods:initialize,check_version,pipeline_hash,apply_code_entry,drop_code_entry,sync_with_registry(phase 2 incremental diff),sync_randvars(phase 3 divergence-point rewind-and-replay),save,print.drop_code_entryuses metadata prediction via the new file-level.entry_columns(reg)helper rather than a runtime column map.-
New
RegistryStudymethods:-
$register_framework(fn)and$register_randvars(name, fn)for phase 1 / phase 3 registration. -
$code_registry_fingerprints()returning xxhash64 digests of eachcode_registryentry’s(codes, label, groups, fn_args, combine_as)tuple. -
$pipeline_hash()returning a single xxhash64 over (framework hash, ordered randvars sequence hashes, code registry fingerprints). Answer to “what would a freshly-built skeleton look like?” -
$load_skeleton(batch_number)/$save_skeleton(sk)as thin wrappers aroundSkeleton$save()that supplyself$data_skeleton_dirautomatically, mirroring the existing$load_rawbatch()/$save_rawbatch()pattern. -
$skeleton_pipeline_hashes()returning a per-batch summarydata.tablewith each skeleton’s currentpipeline_hash. Useful for spotting batches out of sync with each other. -
$assert_skeletons_consistent()as a pre-flight check for downstream consumers: errors on mixed-hash or partially-rebuilt state. -
$write_pipeline_snapshot()writing a one-row TSV to{data_pipeline_snapshot_dir}/{host_label}.tsv. Git-trackable, concurrency-safe (each host writes only its own file), silently skipped when the snapshot candidate directory is not configured or not mounted on the current host. -
$adopt_runtime_state_from(other)copies runtime fields (n_ids,n_batches,batch_id_list,groups_saved) from anotherRegistryStudywithout touching config fields. Used by generator scripts to reload disk state without silently adopting stalegroup_namesorcode_registry.
-
New
RegistryStudypublic fields:framework_fn,randvars_fns(named ordered list),host_label,data_pipeline_snapshot_cp([CandidatePath]).New active binding
data_pipeline_snapshot_dirresolving fromdata_pipeline_snapshot_cp, parallel to the existingdata_rawbatch_dir/data_skeleton_dir/data_raw_diraccessors.File-level helpers in
r6_registrystudy.R:.hash_function(fn)(xxhash64 overlist(body(fn), formals(fn))– stable across R sessions because it excludes the function environment),.fingerprint_entry(reg),.entry_columns(reg)(vectorized wrapper around the previously-private.generated_columns_for_entry()which has been lifted to file level),.format_batch_range(batches), and.process_one_batch(study, i, ...)(the per-batch orchestration shared byprocess_skeletons()’s serial and callr-parallel branches).
Memory footprint note
$load_skeleton() calls data.table::setalloccol(obj$data, n = getOption("datatable.alloccol", 4096L)) on the loaded Skeleton$data to restore data.table over-allocation slots that qs2 serialization drops. Memory overhead is a new data.table HEADER (~8-16 bytes per slot, so ~32-64 KB total) – NOT a full copy of the column data, which stays shared by reference. This is negligible compared to the per-batch skeleton size (typically multi-GB). Without the refresh, subsequent := mutations inside helper functions would silently reallocate and strand the R6 field pointing at the stale old-address data.table. Studies that need more than 4096 column slots can bump via options(datatable.alloccol = 8192L) at the top of their generator script.
Other changes
- 6 existing
process_skeletonstest cases were rewritten to use the new$register_framework()idiom. - Added
tests/testthat/test-r6_skeleton.R(74 tests),tests/testthat/test-process_skeletons_incremental.R(47 tests),tests/testthat/test-entry_columns_parity.R(10 tests), and 56 new unit tests intest-registrystudy.Rfor the new methods. - Total test count: 800 tests passing.
swereg 26.4.18
BREAKING CHANGES
TTEPlanschema bump (1 → 2) and new constructor signature:tteplan_from_spec_and_registrystudy()now requirescandidate_dir_spec,candidate_dir_tteplan,candidate_dir_results, andspec_version. The previous positionalspecargument is removed — the spec YAML is now read from inside the resolvedcandidate_dir_specusingfilename_spec(spec_version). Existing_plan.qs2files error on load viacheck_version(); regenerate by re-runnings0_init.Rfor each project.RegistryStudyschema bump (2 → 3): private.data_{rawbatch,skeleton,raw}_dir_candidatesand_cachefields are replaced by publicdata_{rawbatch,skeleton,raw}_cpfields of typeCandidatePath. Existingregistry_study_meta.qs2files error on load; regenerate by re-runningrun_generic_create_datasets_v2.R.-
Stub-free on-disk filenames. Project-scoped directories no longer need a
{project_id}_prefix on files. The rename is:-
registry_study_meta.qs2→registrystudy.qs2 -
{project_id}_plan.qs2→tteplan.qs2 -
{project_id}_spec.xlsx→spec.xlsx -
{project_id}_tables.xlsx→tables.xlsx -
study_spec_vXXX.yaml→spec_vXXX.yamlUsedev/rename_r6_files.shin the downstream repository for the on-disk migration.
-
New features
CandidatePathR6 class (R/r6_candidate_path.R). First-class representation of “a directory that lives at one of several candidate locations depending on host”. Owns its candidate list, its resolution cache, and its$resolve()/$invalidate()/$is_resolved()/$print()methods. BothRegistryStudyandTTEPlannow holdCandidatePathinstances via public*_cpfields, so multi-host path resolution is structurally identical across classes — cannot drift.first_existing_path(candidates, label)(exported). Generic, study-agnostic “first existing path” picker lifted from the old.resolve_path(). Auto-creates the first candidate whose parent directory exists (unchanged from the old behavior).invalidate_candidate_paths(obj)(exported). Walks an R6 object’s public fields and calls$invalidate()on everyCandidatePathit finds, recursing into embedded R6 objects. Called byRegistryStudy$save_meta()andTTEPlan$save()before serialization so on-disk files never carry the saving host’s cached resolved paths.tteplan_locate_and_load(candidate_dir_tteplan)(exported). Stage scripts (s1.R,s2.R,s3.R,s4_export.R) use this one-liner to load atteplan.qs2from the first candidate directory that exists.registrystudy_load(candidate_dir_rawbatch)(exported). Paired withtteplan_from_spec_and_registrystudy()sos0_init.Rreadsregistrystudy.qs2from the first rawbatch directory that exists on the current host.TTEPlanactive bindings for owned paths:dir_tteplan,dir_spec,dir_results_base,dir_results(appendsspec_version),tteplan,spec_path,spec_xlsx,tables_xlsx, plusdata_skeletonanddata_rawbatchthat delegate to the embeddedregistrystudy(no duplication). Stage methods default to these bindings, sos1/s2/s3no longer need an explicitoutput_dir =.Host-portable
skeleton_filesafter load.tteplan_load()now refreshesplan$skeleton_filesfromplan$registrystudy$skeleton_fileson every load, reapplying then_skeleton_files_limitstored on the plan. A plan saved on one host and loaded on another immediately points at the current host’s skeleton files.
swereg 26.4.17
New features
-
Custom Table 1 engine:
sweregnow ships its own Table 1 builder (.swereg_table1()), replacing the optionaltableonedependency.TTEEnrollment$table1()returns a long-formatdata.tablewith classswereg_table1and supports new arguments:-
arm_labels— display labels for the two exposure arms -
include_smd— toggle SMD column (defaults TRUE) -
show_missing— annotate variable names with"(missing X.X%)"instead of emitting a separateMissingrow (defaults TRUE). Percentages for non-missing levels are computed against the non-missing denominator, so they sum to 100 within each column. Multi-level categorical SMDs follow the Yang & Dalton (2012) generalisation.
-
Forest plot for Table 3: the workbook’s Table 3 sheet is now a forest plot rendered with
ggplot2. SupplementalTable S{n+1}keeps the full tabular IRR for all ETTs (per-protocol truncated). The forest plot is delivered as a high-resolution PNG (300 dpi) embedded in the worksheet, plus a vector PDF sidecar saved next to the workbook.CONSORT flowcharts:
.write_consort()now renders a Graphviz DOT flowchart viaDiagrammeR+DiagrammeRsvg+rsvg, embeds the PNG into the worksheet, and saves PNG/PDF sidecars next to the workbook. Falls back to the legacy text-table layout when the optional packages are missing.featured_ettsargument onTTEPlan$export_tables(): filters Tables 2 and 3 to a user-specified subset of ETT ids; the supplementary tabular IRR remains unfiltered. An “Exposure definitions” legend block is written above each main table, and the_Exposed/_Unexposedcolumn suffixes are rewritten to spec-derived arm labels when all featured ETTs share a single enrollment.TTEPlan$reload_spec(): refreshes cosmetic spec fields (study title, enrollment names, exposure-arm labels, outcome names, ETT descriptions) from a YAML spec on disk WITHOUT re-running the upstream pipeline. Structural changes (confounders, exclusion criteria, follow-up windows, matching parameters, etc.) are detected and reported via a loud warning but NOT applied — cached results stay bound to the old definitions. The new fieldsspec_reloaded_atandspec_reload_skipped_diffsare surfaced on the Provenance sheet.TTEPlan$recompute_baselines(): re-runs the new Table 1 engine on cached enrollment files in-process, used to refresh stale baseline tables after upgrading swereg without re-running$s3_analyze().$export_tables()calls this lazily when it detects pre-26.4.17 cached Table 1 results.
Workbook output changes
- Supplementary baseline panels are renamed:
-
Raw→Unimputed and unweighted -
Unweighted→Imputed and unweighted -
IPW→Imputed and IPW -
IPW Truncated→Imputed and IPW truncated
-
- The main Table 1 sheet hides the SMD column and missingness annotations; supplementary panels include both.
Breaking changes
-
TTEEnrollment$table1()now returns adata.table(classc("swereg_table1", "data.table", "data.frame")) instead of atableoneS3 object. Code that introspectedTableOnefields will need to read from the long-format columns instead. -
tableoneis removed fromSuggests. Cachedresults_enrollmentlists produced by older versions are recognised and refreshed lazily on the next$export_tables()call (anoutput_diris required for the refresh). -
ggplot2is now inImports(wasSuggests) since the forest plot is a mandatory part of$export_tables(). -
DiagrammeR,DiagrammeRsvg, andrsvgare added toSuggests. They are required for CONSORT flowcharts; without them$export_tables()silently falls back to the legacy text CONSORT.
swereg 26.4.16
Improvements
-
setup_progress_handlers(): Pickhandler_progress()format based oninteractive(). In interactive sessions use\r-based single-line repaint (clear = TRUE, no trailing newline) so the bar updates in place like a normal terminal progress bar. In non-interactive sessions (RStudio background jobs, Rscript, CI) use a trailing\nwithclear = FALSEso each step is a new line in the log.
swereg 26.4.15
Improvements
-
RegistryStudy$process_skeletons(): Pass the current timestamp as the progressmessage(both sequential and parallel paths), matching the convention already used inparallel_pool(). The(last: :message)suffix in thesetup_progress_handlers()format string now shows the clock time of the last completed batch (e.g.(last: 14:35:22)) so you can tell at a glance whether the job is making progress or frozen. Previously calledp()with no message, so(last: )was always blank.
swereg 26.4.14
Bug Fixes
-
setup_progress_handlers(): The real reason progress never showed up in RStudio background jobs –progressrsilently suppresses reporting in non-interactive sessions unless you setoptions("progressr.enable" = TRUE). Background jobs haveinteractive() == FALSE, so the global handler was being installed correctly butprogressor()calls were emitting no output. Now forces the option on. Also restores(last: :message)in the format so you can tell the bar isn’t frozen by watching the item label advance.
swereg 26.4.13
Improvements
-
setup_progress_handlers(): Drop thehandler_rstudio/ rstudioapi branch entirely. Usehandler_progress()withformat = "[:bar] :current/:total (:percent) in :elapsedfull, eta: :eta\n"andclear = FALSEin every context — same recipe ascs9::set_progressrandplnr::Plan$run_all. The trailing\nmakes each update a new log line (instead of a\rrepaint that job logs can’t render), andclear = FALSEkeeps finished bars in the scrollback. Works in interactive R, RStudio’s foreground console, and RStudio background-job subprocesses without any detection logic or handler switching.handler_rstudiohas been ineffective for the background-job case in this codebase.
swereg 26.4.12
Improvements
-
setup_progress_handlers(): Collapse the RStudio-detection logic to a singlerstudioapi::isAvailable(child_ok = TRUE)call. Thechild_ok = TRUEparameter handles both the foreground RStudio console and background-job subprocesses (via IPC to the parent session), so all the earlierhasFun/exists/isJobgymnastics were unnecessary. Also drops the now-redundant feature-test forjobAdd/jobSetProgress/jobRemove—progressr::handler_rstudiorequires those to exist anyway.
swereg 26.4.11
Bug Fixes
-
setup_progress_handlers(): Stop usingrstudioapi::hasFun("jobAdd")to feature-test for the job wrappers.hasFun()(a) short-circuits toFALSEwheneverisAvailable()isFALSE, and (b) looks the name up in the internalrstudionamespace where the function is actually calledaddJob(therstudioapi::jobAdd()wrapper forwards tocallFun("addJob", ...)). SohasFun("jobAdd")returnedFALSEeven on systems wherejobAdd()works fine, which caused the helper to fall through to the text handler every time. Now checks therstudioapiwrapper namespace directly viaexists("jobAdd", envir = asNamespace("rstudioapi"), mode = "function").
swereg 26.4.10
Bug Fixes
-
setup_progress_handlers(): Fix detection of RStudio background-job subprocesses. Previously relied onrstudioapi::isAvailable(), which returns FALSE inside ajobRunScript()subprocess (because.Platform$GUIis not “RStudio” there) — so scripts launched via Source as Background Job fell through to the text handler and no Jobs-pane progress bar appeared. Now also acceptsrstudioapi::isJob()as a valid context; in job subprocesses,rstudioapi::callFun()auto-delegatesjobAdd/jobSetProgress/jobRemoveback to the parent RStudio session via IPC, sohandler_rstudioworks correctly.
swereg 26.4.9
New Features
-
setup_progress_handlers(): Helper for run scripts. Feature-detectsrstudioapi::jobAdd()and installsprogressr::handler_rstudio()when available, else falls back tohandler_progress(). Fixes the “no good progress bar” problem when launching run scripts via RStudio’s Source as Background Job menu — the default text bar renders badly in job logs, the RStudio handler draws a proper Jobs-pane progress bar. Automatically covers every progressr-emitting method (process_skeletons,s1_*,s2_*,s3_*) with no per-method changes.
swereg 26.4.8
New Features
-
RegistryStudy$compute_population(by): Compute a population denominator table from saved skeleton files. Counts unique persons byisoyearand user-specified structural variables (e.g. sex, age, register tag). Handles both annual and weekly skeleton rows viauniqueN(id). Produces a complete grid with all combinations (missing cells filled with zero). Saves result aspopulation.qs2in the skeleton directory.
swereg 26.4.7
Improvements
-
s3_analyze()now prints the output directory path and count of .qs2 files before processing. -
s3_analyze()gainsett_idsparameter to run only specific ETTs (e.g.ett_ids = "ETT01"). - Remove heterogeneity test (
het_test) froms3_analyze()— a single call consumed 42GB RAM and 40+ minutes CPU on real data, making full runs infeasible. - Remove
het_slotparameter fromtteenrollment_irr_combine()(no longer needed).
swereg 26.4.6
Bug Fixes
- Fix latent bug in
parallel_pool():Filter(Negate(is.null), results)removed NULLs and shifted indices, breaking positionalitem_mapindexing ins3_analyze. Workers that fail already raise errors before producing output, so NULL results cannot occur.
Internal
- Deduplicate
.write_combined_rates()and.write_combined_irr()into shared helper.prepare_combine_data(). - Extract
.build_code_lookup()helper for code_lookup + fmt_var construction, shared byprint_spec_summary()and.write_spec_summary(). - Call
parallel::detectCores()once at top ofs3_analyze()instead of twice in the enrollment and ETT loops.
swereg 26.4.5
New Features
-
$s3_analyze(): New Loop 3 method on TTEPlan that computes all analysis results (baseline characteristics, rates, IRR, heterogeneity tests) and stores them on the plan. Split into enrollment-level results (table1 variants) and ETT-level results (outcome-specific). Degenerate ETTs (GLM failure) are caught and stored with a skip reason instead of crashing. Progress bars via progressr. -
$results_summary(): Print diagnostic table showing event counts and IRR/rates status per ETT. -
$export_tables(path): Export all results to a multi-sheet Excel workbook: enrollment overview, ETT overview, Table 1 (chosen enrollment), Tables 2-3 (combined rates/IRR), per-enrollment combined baselines (4-panel: Raw/Unweighted/IPW/IPW Truncated), CONSORT attrition sheets, supplemental rates/IRR. -
tteplan_load(path): Load a TTEPlan from disk with the current class definition, ensuring new methods are available on old serialized objects. -
$s1_generate_enrollments_and_ipw(resume = TRUE)and$s2_generate_analysis_files_and_ipcw_pp(resume = TRUE)skip completed work based on file timestamps (must be <24h old).
Bug Fixes
- Fix
'from' must be of length 1crash inenroll()when a skeleton file has no enrolled persons for the current enrollment. data.table evaluatesjonce on 0-row data even withby, giving by-variables length 0 instead of scalar. This also produced spurious-Infwarnings frommax(logical(0), na.rm = TRUE)in Phase B. Fix: short-circuitenroll()with an empty panel whenentry_dthas 0 rows. - IPCW GLM fallback:
fit_and_predict()ins6_ipcw_ppnow uses tryCatch; falls back to marginal censoring rate when the model fails (e.g. near-zero events).
swereg 26.4.3
Breaking Changes
-
parallel_pool()rewritten to useprocessx+ qs2 tempfiles instead offuture.callr. Worker logic moved to standalone R scripts ininst/(worker_s1a.R,worker_s1b.R,worker_s2.R), launched viaprocessx::process$new(). All data passes through qs2 files on disk instead of R’s IPC serialization, fixing the loop 1b bottleneck whereenrolled_idswas serialized N times through pipe buffers.enrolled_idsis now written once to a shared tempfile. Dependenciesfuture,future.apply,future.callrremoved;processxadded.
swereg 26.4.2
Breaking Changes
-
parallel_pool()rewritten to usefuture.callrinstead of persistentcallr::r_sessionworkers. Each work item now runs in a fresh R subprocess, eliminating deadlocks caused by accumulated IPC socket state. New dependencies:future,future.apply,future.callr. Theprocessxdependency is removed.callr_kill_workers()is removed (no longer needed).
swereg 26.3.30
Improvements
-
callr_pool()gains atimeout_minutesparameter (default: 30). If a work item runs longer than the timeout, its worker is killed and the item is retried once. If the retry also times out,callr_pool()callsstop(). Disable withtimeout_minutes = NULL.
CRAN compliance
- Move
mgcvfrom Imports to Suggests (only used conditionally viarequireNamespace()). - Add
@importFromforprogressrandutils::getFromNamespaceto satisfy NAMESPACE checks. - Replace
swereg:::calls withgetFromNamespace()in callr worker sessions. - Replace
assign(..., globalenv())with a package-level environment (.swereg_env). - Add
var <- NULLdeclarations for all data.table NSE variables. - Add
.vscodeto.Rbuildignore.
swereg 26.3.23
Improvements
-
callr_pool()workers now self-terminate if the parent R session dies (e.g. OOM kill). Each worker spawns a lightweight shell watchdog that polls the parent PID every 5 seconds. Previously, orphaned workers ran indefinitely until manually cleaned up viacallr_kill_workers().
Bug Fixes
Critical:
.s1_eligible_tuples()usedfirst(rd_exposed)to classify exposure at each trial period, which only detected treatment initiation if it happened on the first week of a 4-week trial period. Withperiod_width = 4, ~75% of exposed people start treatment mid-period and were silently dropped — their first trial period showed them as unexposed (week 1 was pre-initiation), and the next period excluded them for prior treatment. Fixed by usingany(rd_exposed, na.rm = TRUE)instead. The existingno_prior_exposureexclusion correctly handles the new-user restriction. Verified: eligible exposed count on skeleton_001 went from 19 → 84, matching the old per-week pipeline..s1_compute_attrition(): exposure classification now usesany()per person-trial instead of checking the first eligible row. Aligns attrition reporting with theany()fix in.s1_eligible_tuples()— previously the attrition flow underreported exposed counts by ~4x.tteplan_validate_spec(): missing variables (confounders, outcomes, exclusion criteria, exposure) nowstop()instead ofwarning(). Previously, a misspelled or renamed variable would silently pass validation and break downstream (e.g. IPW model missing a confounder). Category mismatches (values in spec but not data) remain as warnings since they can occur in small batches.
swereg 26.3.20
Bug Fixes
.s1_compute_attrition(): fix undercounting of person-trials for row-level eligibility criteria (e.g.eligible_valid_exposure). The old code checked only the first row per person-trial, missing cases where exposure onset occurred after the first week. The new approach filters to eligible rows first, then counts — matching the logic used by.s1_eligible_tuples()..s1_compute_attrition(): fix negative exposed/comparator deltas in participant flow. Thebefore_exclusionsbaseline now classifies exposure from the first row with non-NA exposure per person-trial, rather than the first overall row (which often hasrd_exposed = NA). Total person-trial counts remain unfiltered.
Performance
- TTE s1 pipeline: add
data.table::setkey()calls to eliminate redundant hash-based grouping. Skeleton reads in.s1_prepare_skeleton()and.s1b_worker()now set key on(id, isoyearweek)(metadata-only, no re-sort).enroll()Phase B collapse uses keyed grouping on(pid, trial_id), and Phase D panel expansion uses keyed binary join instead ofmerge().
Bug Fixes
callr_pool()PID files now written to/tmpinstead oftempdir()so that orphaned workers from crashed R sessions can be discovered and cleaned up by new sessions.callr_kill_workers()simplified to orphan-only cleanup: kills workers whose parent R process is dead and removes stale PID files. Own-session cleanup is already handled bycallr_pool()’son.exit()handler; this function is only needed after hard crashes (SIGKILL, OOM).
Performance
callr_pool()now uses persistentcallr::r_sessionworkers instead of spawning a freshcallr::r_bg()process per work item. The swereg namespace is loaded once per worker slot rather than once per item, eliminating redundant startup overhead when scaling to large numbers of items.Orphan protection:
callr_pool()writes a PID file per invocation and cleans up orphaned worker sessions from previous crashed runs (e.g. OOM kills) on the next invocation.
Bug Fixes
- Fixed 3 test failures in
test-tte_spec.Rcaused by s1 pipeline changes: added missingrd_exposedcolumn to.s1_compute_attritiontest fixtures, addedn_exposed/n_unexposedto mock attrition data, and updated matching output expectations.
Performance
s1_generate_enrollments_and_ipw()now caches prepared skeletons between s1a (scout) and s1b (enrollment) passes, eliminating redundant file reads and exclusion processing. Expected ~30-40% reduction in per-enrollment wall-clock time..s1b_worker()now subsets the skeleton to enrolled persons before computing derived confounders, avoiding expensive rolling-window operations on non-enrolled persons.TTEEnrollment$new()acceptsown_data = TRUEto skip the defensivedata.table::copy()when the caller will not reuse the data. Used in.s1b_worker()where the skeleton is discarded immediately after.enroll()Phase B now aggregates confounders, time-exposure, and outcome columns in a single groupby pass instead of four separate passes with merges.
Improvements
“Valid exposure” (
eligible_valid_exposure) is now the first exclusion criterion in the TTE attrition flow. Rows whererd_exposedis NA are explicitly accounted for rather than silently disappearing between the before-exclusions total and the first real criterion.TARGET Item 8 (participant flow) now shows a richer flow diagram with before-exclusion counts, per-step exposed/unexposed breakdown, delta (excluded) and remaining counts at each criterion, right-justified aligned columns, and color-coded output (red for exclusions, cyan for remaining). Post-matching line also reformatted with arrow indicator. “Before exclusions” line no longer shows a meaningless exposed/comparator breakdown.
enrollment_counts$attritionnow includesn_exposedandn_unexposedcolumns and a"before_exclusions"row.
Bug Fixes
Fixed
trial_idmissing error caused byattr<-breaking data.table’s internal self-reference. Replaced withdata.table::setattr()in.s1_prepare_skeleton()andtteplan_apply_exclusions()to preserve in-place modification semantics.Fixed callr worker stale-namespace bug: after
devtools::load_all()in a subprocess, worker functions still referenced the old (installed) swereg namespace. Now rebinds the worker function’s environment to the freshly-loaded namespace.
Improvements
Reorganized
print_spec_summary()header layout: renamed “Study created” → “RegistryStudy”, merged “Skeletons created” + “Skeleton files” into a single nested line with tree connector, renamed “Plan created” → “TTEPlan”, and reordered to follow data pipeline order.Rewrote TARGET checklist items 6c, 6h, and 7a-h in
print_target_checklist()as academic prose suitable for copy-pasting into a methods section. Item 6c now dynamically reflects per-enrollment matching ratios from the spec.
Breaking changes
enrollment_countsstructure changed: Each element ofTTEPlan$enrollment_countsis now a list with$attritionand$matchingsub-elements (was a single data.table). Code accessingplan$enrollment_counts[["01"]]directly as a data.table must update toplan$enrollment_counts[["01"]]$matching.person_trial_idrenamed toenrollment_person_trial_id: The composite key column now has a 3-part name matching its 3-part format (enrollment_id.person_id.trial_id). All code referencingperson_trial_idmust be updated.process_fnparameter removed from$s1_generate_enrollments_and_ipw(): The two-pass spec-driven pipeline is now the only code path.self$specis required (create plans withtteplan_from_spec_and_registrystudy()). The legacy single-pass.s1_worker()has been deleted..s2_worker()renamed to.s3_worker(): Internal Loop 2 IPCW-PP worker renamed to avoid confusion with the two-pass Loop 1 pipeline.
New features
-
Two-pass enrollment pipeline:
$s1_generate_enrollments_and_ipw()now uses a two-pass pipeline that fixes cross-batch matching ratio imbalance:-
Pass 1a (scout): Lightweight parallel pass collecting eligible
(person_id, trial_id, exposed)tuples from all batches. -
Centralized matching: Combines all tuples and performs per-
trial_idmatching globally, ensuring the correct ratio across all batches. - Pass 1b (full enrollment): Parallel pass using pre-matched IDs to enroll without per-batch matching.
-
Pass 1a (scout): Lightweight parallel pass collecting eligible
enrollment_countson TTEPlan: New field storing per-trial matching counts (total vs enrolled, exposed vs unexposed) for TARGET Item 8 reporting..assign_trial_ids(): New shared helper function that is the single source of truth forisoyearweek -> trial_idmapping. Used consistently by both scout (s1a) and enrollment (s1b/enroll) phases.enrolled_idsparameter onTTEEnrollment$new(): When provided, enrollment skips the matching phase and uses pre-decided IDs directly, enabling the two-pass pipeline.Per-criterion attrition counts for TARGET Item 8: The scout pass (s1a) now computes cumulative person and person-trial counts at each eligibility step. Stored in
plan$enrollment_counts[["01"]]$attritionas a long-format data.table with columnstrial_id,criterion,n_persons,n_person_trials.$print_target_checklist()Item 8 auto-populates with these counts when available.
swereg 26.3.21
New features
$heterogeneity_test(): New method onTTEEnrollmentthat tests for heterogeneity of treatment effects across trials via a Wald test on thetrial_id × exposureinteraction (Hernán 2008, Danaei 2013).$print_target_checklist(): New method onTTEPlanthat generates a self-contained TARGET Statement (Cashin et al., JAMA 2025) 21-item reporting checklist. Auto-populates items from the study spec and provides[FILL IN]placeholders for PI completion.
Improvements
$irr()calendar-time adjustment: Outcome model now includestrial_idas a covariate to adjust for calendar-time variation in outcome rates across enrollment bands (Caniglia 2023, Danaei 2013). Usesns(trial_id, df=3)for ≥5 unique trial IDs, linear term for 2-4, omitted for 1.$irr()IPW-only guard:$irr()now rejects IPW-only weight columns (ipw,ipw_trunc) after per-protocol censoring has been applied. The swereg pipeline applies per-protocol censoring in$s4_prepare_for_analysis(), so only per-protocol weights (analysis_weight_pp_trunc) are valid for the censored dataset.
Documentation
Methodology vignette: New
vignette("tte-methodology")maps the swereg TTE implementation to five reference papers (Hernán 2008/2016, Danaei 2013, Caniglia 2023, Cashin 2025). Documents which methods are implemented, which are not, and design rationale.Analysis types:
vignette("tte-nomenclature")now documents that swereg supports per-protocol analysis only. ITT analysis is not supported because the pipeline censors at protocol deviation. As-treated analysis requires time-varying IPW (not implemented).period_widthdocumentation:vignette("tte-nomenclature")now explains the enrollment band width / residual immortal time bias trade-off, citing Caniglia (2023) and Hernán (2016).Matching approach:
vignette("tte-nomenclature")now documents the per-band stratified matching design choice and alternatives from the literature.$s2_ipw()documentation: Clarified that IPW estimates the propensity score for baseline treatment assignment only, not time-varying treatment weights.$irr()documentation: Documented IRR ≈ HR for rare events,ns(tstop)for flexible baseline hazard,quasipoissonfor overdispersion, and computational equivalence to pooled logistic regression.IPCW stabilization: Documented the simplified marginal stabilization approach and its relationship to Danaei (2013).
swereg 26.3.20
Improvements
-
Band-based enrollment: Added explicit
isoyearweekordering before band-level collapse to prevent silent misclassification when input data is not pre-sorted by time. -
IPCW-PP: Censoring model now includes
trial_idto account for calendar-time variation in censoring patterns across enrollment bands. -
person_weeks: Now computed from actual source row counts during band collapse instead of hardcodedperiod_width. Partial-coverage bands (e.g., at data boundaries) now contribute accurate person-time.
Breaking changes
-
$irr(): Removed the constant (no time adjustment) Poisson model. Only the flexible model with natural splines (splines::ns(tstop, df=3)) is retained. Output columns renamed:IRR_flex→IRR,IRR_flex_lower→IRR_lower,IRR_flex_upper→IRR_upper,IRR_flex_pvalue→IRR_pvalue,warn_flex→warn. AllIRR_const*andwarn_constcolumns removed. -
tteenrollment_irr_combine(): Updated to match new$irr()output. Columns renamed:IRR (flexible)→IRR,95% CI (flexible)→95% CI,p (flexible)→p. Constant-model columns removed. -
TTE ID semantics: The composite person-per-trial identifier column is now called
person_trial_id(wastrial_id). The actual trial identifier (the enrollment band) is now exposed astrial_idin enrollment output. This fixes the semantics sotrial_idmeans the trial andperson_trial_ididentifies a person’s participation in a trial. -
TTEDesign default:
id_vardefault changed from"trial_id"to"person_trial_id". -
s1_impute_confounders(): No longer hardcodestrial_id; usesdesign$id_varthroughout.
Code quality
- Rename private methods
prepare_outcomeandipcw_pptos5_prepare_outcomeands6_ipcw_ppto signal their execution order withins4_prepare_for_analysis(). - Reorder
TTEEnrollmentpublic step methods to match their numeric sequence (s1 before s2).
Breaking changes
Band-based enrollment:
TTEEnrollmentenrollment now uses N-week bands (controlled byperiod_widthinTTEDesign, default 4). Calendar time is grouped into bands based onisoyearweek, matching is done per-band (stratified), and data is collapsed to band level during enrollment. This eliminates the separate$s1_collapse()step entirely.-
Step renumbering: Public workflow methods on
TTEEnrollmenthave been renumbered after removing$s1_collapse():-
$s2_impute_confounders()->$s1_impute_confounders() -
$s3_ipw()->$s2_ipw() -
$s4_truncate_weights()->$s3_truncate_weights() -
$s5_prepare_for_analysis()->$s4_prepare_for_analysis()
-
period_widthparameter: Moved fromTTEPlan$s1_generate_enrollments_and_ipw()toTTEDesign$new(period_width = 4L). Now part of the design contract.isoyearweekcolumn required: Band-based enrollment requires anisoyearweekcolumn in person-week data.Schema version bump:
TTEDesignandTTEEnrollmentschema versions bumped to 2. Objects saved with version 1 will warn on load.
New features
TTEPlan provenance timestamps: TTEPlan now tracks
created_at(stamped at construction),registry_study_created_at(from the source RegistryStudy), andskeleton_created_at(from the first skeleton file’s attribute). All three timestamps are shown inprint()andprint_spec_summary()when available, making it easy to detect stale plans.R6 schema versioning: All R6 classes (
RegistryStudy,TTEPlan,TTEDesign,TTEEnrollment) now carry a.schema_versionprivate field, stamped at construction time. A new$check_version()public method compares the stored version against the current class definition and warns when stale.qs2_read()automatically calls$check_version()on R6 objects after loading, so outdated serialized objects produce a clear warning instead of silently breaking.Deprecation warnings for old
add_*parameter names:add_diagnoses(diags=),add_operations(ops=),add_rx(rxs=),add_icdo3s(icdo3s=),add_snomed3s(snomed3s=), andadd_snomedo10s(snomedo10s=)now emit a deprecation warning when the old parameter name is used. Usecodes=instead.
Breaking changes
RegistryStudy:
register_codes()now takes a declarative signature:register_codes(codes, fn, groups, fn_args, combine_as). Each call declares codes, the function to apply them, which data groups to use, and optional prefix/combine behavior. The five old per-type code fields are removed, together with the oldregister_codes(icd10_codes = ...)signature. The singlecode_registrylist field replaces them.summary_table(): Thetypeparameter is removed. Thetypecolumn is replaced bylabel. Uselabelto filter.add_diagnoses(),add_operations(),add_rx(),add_icdo3s(),add_snomed3s(),add_snomedo10s(): The codes parameter is renamed tocodes(wasdiags,ops,rxs,icdo3s,snomed3s,snomedo10s). Old parameter names still work for backwards compatibility.
Refactoring
- Moved
qs2_read()to its own file (R/qs2.R) and inlined the fallback logic directly. Removed pointless.qs_savewrapper (replaced with directqs2::qs_savecalls) and.qs_readinternal helper.
Breaking changes
skeleton_save()no longer splits batches into sub-files. It saves one file per batch asskeleton_NNN.qs2(wasskeleton_NNN_SS.qs2). Theids_per_fileandid_colparameters have been removed.RegistryStudy:batch_sizesparameter (integer vector) replaced withbatch_size(single integer, default 1000). Theids_per_skeleton_fileparameter has been removed. All batches are now uniform size.
swereg 26.3.21
Breaking changes
-
RENAMED: Standalone TTE functions renamed to signal which class they operate on:
-
tte_rbind()→tteenrollment_rbind() -
tte_rates_combine()→tteenrollment_rates_combine() -
tte_irr_combine()→tteenrollment_irr_combine() -
tte_impute_confounders()→tteenrollment_impute_confounders() -
tte_read_spec()→tteplan_read_spec() -
tte_apply_exclusions()→tteplan_apply_exclusions() -
tte_apply_derived_confounders()→tteplan_apply_derived_confounders() -
tte_validate_spec()→tteplan_validate_spec() -
tte_plan_from_spec_and_registrystudy()→tteplan_from_spec_and_registrystudy() -
tte_callr_pool()→callr_pool()
-
-
RENAMED: Eligibility helpers renamed from
tte_eligible_*toskeleton_eligible_*to reflect that they operate on skeleton data.tables, not TTE classes:-
tte_eligible_isoyears()→skeleton_eligible_isoyears() -
tte_eligible_age_range()→skeleton_eligible_age_range() -
tte_eligible_no_events_in_window_excluding_wk0()→skeleton_eligible_no_events_in_window_excluding_wk0() -
tte_eligible_no_observation_in_window_excluding_wk0()→skeleton_eligible_no_observation_in_window_excluding_wk0() -
tte_eligible_no_events_lifetime_before_and_after_baseline()→skeleton_eligible_no_events_lifetime_before_and_after_baseline() -
tte_eligible_combine()→skeleton_eligible_combine()
-
File reorganization
-
RENAMED:
R/tte_enrollment_r6.R→R/r6_tteenrollment.R -
RENAMED:
R/tte_plan_r6.R→R/r6_tteplan.R -
RENAMED:
R/registry_study_r6.R→R/r6_registry_study.R -
EXTRACTED:
callr_pool()to its own fileR/callr_pool.R -
MOVED: Eligibility helpers to
R/skeleton_utils.R -
MOVED:
tteenrollment_impute_confounders()toR/r6_tteenrollment.R
swereg 26.3.20
Breaking changes
-
RENAMED: TTEEnrollment public workflow methods now have step-number prefixes to signal execution order:
-
$collapse()→$s1_collapse() -
$impute_confounders()→$s2_impute_confounders() -
$ipw()→$s3_ipw() -
$truncate()→$s4_truncate_weights() -
$prepare_for_analysis()→$s5_prepare_for_analysis()
-
RENAMED:
$s4_truncate()→$s4_truncate_weights()for clarity.-
RENAMED: TTEPlan orchestration methods now have step-number prefixes:
-
$generate_enrollments_and_ipw()→$s1_generate_enrollments_and_ipw() -
$generate_analysis_files_and_ipcw_pp()→$s2_generate_analysis_files_and_ipcw_pp()
-
-
RENAMED: Internal worker functions for consistent naming:
-
.tte_process_skeleton()→.s1_worker() -
.loop2_worker()→.s2_worker()
-
REMOVED: Constructor wrapper functions
tte_design(),tte_enrollment(), andtte_plan(). UseTTEDesign$new(),TTEEnrollment$new(), andTTEPlan$new()directly. The auto-detection and data-copy logic fromtte_enrollment()has been moved intoTTEEnrollment$new().
Improvements
REFACTOR: Inlined 5 of 6 private helper methods into their single callers on TTEEnrollment (
.calculate_ipw,.calculate_ipcw,.combine_weights_fn,.match_ratio,.collapse_periods). Kept.truncate_weightsas private (used in 2 places). Reduces indirection for stateless methods that don’t useself.TESTS: Rewrote
test-tte_weights.Rto test through public API ($s1_collapse(),$s3_ipw(),$s4_truncate(),tte_enrollment(ratio=)) instead of accessing inlined private methods.
swereg 26.3.20
Improvements
REFACTOR: Inlined 6 weight/matching functions as private methods on TTEEnrollment (tte_truncate_weights, tte_calculate_ipw, tte_calculate_ipcw, tte_combine_weights, tte_match_ratio, tte_collapse_periods). Removed 2 orphaned functions (tte_identify_censoring, tte_time_to_event). Users access this functionality through R6 methods ($collapse, $ipw, $truncate, etc.).
-
REFACTOR: Consolidated TTE source files from 7 to 2 (+1 rename):
-
tte_design.R+tte_enrollment.R+tte_weights.Rmerged intotte_enrollment_r6.R(TTEDesign + TTEEnrollment + all weight/matching functions called by their methods) -
tte_plan.R+tte_spec.R+tte_eligibility.Rmerged intotte_plan_r6.R(TTEPlan + spec functions + eligibility helpers) -
registry_study.Rrenamed toregistry_study_r6.R - Files containing R6 classes now have
_r6suffix for discoverability
-
REORDER: TTEEnrollment public methods now follow workflow execution order: collapse -> ipw -> impute_confounders -> truncate -> prepare_for_analysis -> extract/summary/diagnostics -> analysis output.
DOCS: Added inline comments documenting data flow in
generate_enrollments_and_ipw()(Loop 1),.tte_process_skeleton(),private$enroll(),enrollment_spec(), andadd_one_ett().
swereg 26.3.18
Improvements
Study-specific: Added
rd_approach3b_{single,multiple}exposure variables that collapse two approach3 exposure levels into one combined level. Derived by relabeling the finished approach3 columns. This is valid because a switch between two active exposure types never triggers “previous”.Study-specific: The 2026 study
add_lmed()entry point now creates the exposure variables (rd_approach{1,2,3}_{single,multiple}) internally, via a new internal helper. This consolidates all study LMED logic in the package. It removes the need for a separate step 14 in external workflow scripts.Study-specific: Removed 18 sensitivity columns (
*_sensitivity_60p,*_sensitivity_under60censorallat60,*_sensitivity_under60censorrefat65) from that helper. They had a logic issue: rows in one exposure category at age >= 65 producedNAinstead ofFALSE. Therd_age_continuouscolumn is no longer required as input.
swereg 26.2.22
New features
EXPORTED:
tte_callr_pool()— genericcallr::r_bg()worker pool, generalized from the internal.tte_callr_pool(). New API acceptsitems(list of arg-lists),worker_fn,item_labels, andcollect(FALSE to discard results when workers save directly). Eliminates boilerplate when scripts need their own parallel loops (e.g., Loop 2 IPCW-PP).NEW:
TTEPlan$generate_analysis_files_and_ipcw_pp()— Loop 2 method that runs per-ETT IPCW-PP calculation and saves analysis-ready files. Mirrors$generate_enrollments_and_ipw()(Loop 1). Parameters:output_dir,estimate_ipcw_pp_separately_by_exposure,estimate_ipcw_pp_with_gam,n_workers,swereg_dev_path.
Improvements
MEMORY:
tte_calculate_ipcw()now usesmgcv::bam(discrete = TRUE)instead ofmgcv::gam()whenuse_gam = TRUE.bam()discretizes covariates to avoid forming the full model matrix, dramatically reducing peak memory for large datasets. Model objects are also explicitly freed (rm()+gc()) between exposed/unexposed fits.MEMORY:
$irr()and$km()now subset to only the columns needed before creatingsurvey::svydesign(). Previously the full data.table (all columns) was copied into the survey object. Model objects and intermediate data are freed between fits.
swereg 26.2.21
Breaking changes
RENAMED:
$prepare_for_analysis()parametersestimate_ipcw_separately_by_exposure→estimate_ipcw_pp_separately_by_exposureandestimate_ipcw_with_gam→estimate_ipcw_pp_with_gamfor consistency with the IPCW-PP method they control.-
PRIVATE:
$enroll(),$prepare_outcome(),$ipcw_pp(), and$combine_weights()are now private methods onTTEEnrollment.- Enrollment: use
tte_enrollment(data, design, ratio = 2, seed = 4)instead oftte_enrollment(data, design)$enroll(ratio = 2, seed = 4). - Outcome prep + IPCW: use
$prepare_for_analysis()(unchanged). - Weight combination: handled automatically by
$ipcw_pp()(unchanged). - Tests can access private methods via
enrollment$.__enclos_env__$private$method_name().
- Enrollment: use
swereg 26.2.20
Breaking changes
-
RENAMED:
$prepare_analysis()→$prepare_for_analysis()onTTEEnrollment. The new name better communicates that this method prepares the enrollment for analysis (it is not the analysis itself).
Bug fixes
FIXED: 3 remaining broken test calls (
tte_extract(),tte_summary(),tte_weights()) migrated to R6 method syntax ($extract(),print(),$combine_weights()). Column assertion updated:"weight_pp"→"analysis_weight_pp".FIXED:
$impute_confounders()now appends"impute"tosteps_completed, consistent with all other mutating methods.FIXED:
$ipcw_pp()IPW column guard moved from after IPCW computation to before it (fail-fast).
Documentation
FIXED: Vignette truncation bounds corrected from “0.5th and 99.5th percentiles” to “1st and 99th percentiles” (matching code defaults
lower = 0.01, upper = 0.99).FIXED:
TTEDesignroxygen references to removedtte_match()/tte_expand()replaced with$enroll().FIXED:
$weight_summary()moved from “Mutating” to “Non-mutating” section inTTEEnrollmentroxygen (it only prints, never modifies data).
swereg 26.2.13
New features
NEW:
$prepare_for_analysis()method onTTEEnrollmentmerges$prepare_outcome()+$ipcw_pp()into one step. Parameters:outcome,follow_up,separate_by_exposure,use_gam,censoring_var.NEW:
$enrollment_stageactive binding onTTEEnrollment. Derives lifecycle stage from existing state:"pre_enrollment"→"enrolled"→"analysis_ready". Zero maintenance — readsdata_levelandsteps_completed.
swereg 26.2.11
Breaking changes
-
REMOVED: 19 standalone TTE functions moved to R6 methods on
TTETrial(15 methods) andTTEPlan(4 methods). Pipe chaining (trial |> tte_ipw()) replaced with$-chaining (trial$ipw()).TTETrial methods:
$enroll(),$collapse(),$ipw(),$ipcw_pp(),$combine_weights(),$truncate(),$prepare_outcome(),$impute_confounders(),$weight_summary(),$extract(),$summary(),$table1(),$rates(),$irr(),$km().TTEPlan methods:
$add_one_ett(),$save(),$enrollment_spec(),$generate_enrollments_and_ipw(). -
RENAMED:
TTEPlan$task()→TTEPlan$enrollment_spec(). The method returns enrollment metadata (design, enrollment_id, age_range, n_threads), not a generic task. Theprocess_fncallback parameter convention changes fromfunction(task, file_path)tofunction(enrollment_spec, file_path).Removed exports:
tte_enroll,tte_collapse,tte_ipw,tte_ipcw_pp,tte_weights,tte_truncate,tte_prepare_outcome,tte_extract,tte_summary,tte_weight_summary,tte_table1,tte_rates,tte_irr,tte_km,tte_plan_add_one_ett,tte_plan_save,tte_plan_task,tte_generate_enrollments_and_ipw.Kept standalone:
tte_rbind(),tte_rates_combine(),tte_irr_combine(),tte_impute_confounders()(thin wrapper for callback default). CHANGED: TTE classes (
TTEDesign,TTETrial,TTEPlan) migrated from S7 to R6. Property access changes from@to$(e.g.,trial@data→trial$data,design@id_var→design$id_var). R6 reference semantics eliminate copy-on-write overhead fromtrial$data[, := ...], reducing peak RAM from ~3X to ~2X during the weight-calculation chain (Loop 2).-
FIXED: Three S7
@accessor bugs that silently produced no-ops:-
$ipcw_pp(): dropping intermediate IPCW columns (p_censor, etc.) -
$collapse(): creatingperson_weekscolumn -
$impute_confounders(): deleting old confounder columns before merge All fixed automatically by R6 (in-place modification works).
-
CHANGED:
$ipcw_pp()now inlines weight combination and truncation (was callingtte_combine_weights()andtte_truncate_weights()via function parameters that created extra refcount). Keeps data.table refcount=1 throughout.
swereg 26.2.10
Bug fixes
-
FIXED:
tte_ipw(),tte_ipcw_pp(): in-place joins via S7@accessor now use extract/modify/reassign pattern (dt <- trial@data; dt[...]; trial@data <- dt). The previoustrial@data[i, := ...]silently modified a copy, leaving the S7 object’s data unchanged.
Performance
-
IMPROVED:
tte_ipw(),tte_ipcw_pp(),tte_calculate_ipcw(): replacemerge()with in-place keyed joins (data[i, := ...]), reducing peak RAM from ~3x to ~2x panel size during the weight-calculation chain.
Breaking changes
CHANGED:
tte_ipcw_pp()now also combines weights (ipw * ipcw_pp→analysis_weight_pp), truncatesanalysis_weight_pp, and drops intermediate IPCW columns (p_censor,p_uncensored,cum_p_uncensored,marginal_p,cum_marginal). Callers no longer needtte_weights()+tte_truncate()aftertte_ipcw_pp().RENAMED:
tte_generate_enrollments()→tte_generate_enrollments_and_ipw(). Now computes IPW + truncation once on the full combined enrollment (after imputation), so the per-ETT Loop 2 no longer needs to calltte_ipw(). Newstabilizeparameter (default TRUE) controls IPW stabilization.
New features
NEW:
tte_plan_load()reads a.qs2plan file and reconstructs theTTEPlanS7 object. Companion totte_plan_save().CHANGED:
tte_plan_save()now persistsproject_prefixandskeleton_filesalongsideettandglobal_max_isoyearweek, sotte_plan_load()can fully reconstruct the object.NEW:
skeleton_process()gainsn_workersparameter for parallel batch processing. When > 1, usescallr::r()+parallel::mclapply()to process batches concurrently while avoidingfork()+ data.table OpenMP segfaults.
swereg 26.2.9
Improvements
CHANGED: Migrate serialization from
qs(archived) toqs2..qs_save/.qs_readwrappers now callqs2::qs_save/qs2::qs_read(standard format, preserves S7 objects). All file extensions changed from.qsto.qs2. Thepresetparameter is no longer used.IMPROVED:
tte_rates()now setsswereg_typeandexposure_varattributes on its output;tte_irr()setsswereg_type.RENAMED:
tte_rates_table()→tte_rates_combine(),tte_irr_table()→tte_irr_combine(). New API accepts(results, slot, descriptions)— extracts the rates/irr slot internally, removing the need forlapply(results, [[, "table2")at call sites. Exposure column is now read from theexposure_varattribute instead of guessing viasetdiff().
Breaking changes
CHANGED:
tte_plan_add_one_ett()now requires explicitenrollment_idparameter. Auto-assignment based on follow_up + age_group removed. Validation that design params match within an enrollment_id is preserved.IMPROVED:
print(plan)now shows both enrollment grid and full ETT grid.CHANGED:
tte_plan_add_one_ett()bundlesage_group,age_min,age_max,person_id_varinto anargsetnamed list parameter.time_exposure_varandeligible_varno longer have defaults (must be explicit).exposure_varremoved from interface (hardcoded to"baseline_exposed").RENAMED:
file_idcolumn in theettdata.table →enrollment_id. This makes explicit that ETTs sharing the same follow_up + age_group are processed together as one “enrollment” (shared eligibility, matching, collapse, imputation).RENAMED:
tte_generate_trials()→tte_generate_enrollments(). The function generates enrollments (one per follow_up × age_group), not individual trials.RENAMED:
tte_plan_task()return list keyfile_id→enrollment_id.UPDATED:
print(plan)now shows “Enrollments: N x M skeleton files” instead of “Tasks: N file_id(s) x M skeleton files”.
swereg 26.2.8
Breaking changes
CHANGED:
tte_plan()is now infrastructure-only — takes onlyproject_prefix,skeleton_files,global_max_isoyearweek. Usette_plan_add_one_ett()to add ETTs with per-ETT design parameters.REMOVED: TTEPlan plan-level properties
confounder_vars,person_id_var,exposure_var,time_exposure_var,eligible_var. These are now per-ETT columns in theettdata.table.REMOVED: Internal
.tte_grid()function. The ETT grid is now built incrementally viatte_plan_add_one_ett().ADDED:
TTEPlan@project_prefixproperty (needed for file naming intte_plan_add_one_ett()).
New features
NEW:
tte_plan_add_one_ett()— builder function that adds one ETT row to a plan. Stores design params (confounder_vars, person_id_var, exposure_var, time_exposure_var, eligible_var) per-ETT, allowing different ETTs to use different confounders. Validates that design params match within an enrollment_id (same follow_up + age_group).RENAMED:
TTEPlan@filesproperty →TTEPlan@skeleton_filesfor clarity.
swereg 26.2.7
Breaking changes
-
REFACTORED:
tte_generate_enrollments()(formerlytte_generate_trials()) now takes aTTEPlanobject instead of separate parameters (ett,files,confounder_vars,global_max_isoyearweek). Theprocess_fncallback signature changes fromfunction(file_path, design, file_id, age_range, n_threads)tofunction(task, file_path)wheretaskis a list withdesign,enrollment_id,age_range, andn_threads.
New features
-
NEW:
TTEPlanS7 class bundles ETT grid, skeleton file paths, confounder definitions, and design column names into a single object for trial generation.-
tte_plan(): Constructor function -
tte_plan_task(plan, i): Extract the i-th enrollment task as a list withdesign,enrollment_id,age_range,n_threads -
plan[[i]]: Shorthand fortte_plan_task(plan, i) -
length(plan): Number of unique enrollment_id groups - Supports interactive testing:
task <- plan[[1]]; process_fn(task, plan@skeleton_files[1])
-
swereg 26.2.3
Breaking changes
-
REPLACED:
tte_match()andtte_expand()merged into singlette_enroll()function:- Old workflow:
tte_trial(data, design) |> tte_match(ratio = 2, seed = 4) |> tte_expand(extra_cols = "isoyearweek") - New workflow:
tte_trial(data, design) |> tte_enroll(ratio = 2, seed = 4, extra_cols = "isoyearweek") - The two operations were tightly coupled and always used together
-
tte_enroll()combines sampling (matching) and panel expansion in one step - Records “enroll” in
steps_completed(previously recorded “match” then “expand”)
- Old workflow:
New features
-
NEW: Trial eligibility helper functions for composable eligibility criteria:
-
tte_eligible_isoyears(): Check eligibility based on calendar years -
tte_eligible_age_range(): Check eligibility based on age range -
tte_eligible_no_events_in_window_excluding_wk0(): Check for no events in prior window (correctly excludes baseline week) -
tte_eligible_no_observation_in_window_excluding_wk0(): Check for no specific value in prior window (for categorical variables) -
tte_eligible_combine(): Combine multiple eligibility columns using AND logic - All functions modify data.tables by reference and return invisibly for method chaining
-
Documentation
-
IMPROVED: Clarified that eligibility checks should EXCLUDE the baseline week. Using
cumsum(x) == 0is incorrect because it includes the current week. The new eligibility functions useany_events_prior_to()which correctly excludes the current row.
swereg 26.1.31
New features
-
NEW: S7 object-oriented API for target trial emulation workflows:
-
TTEDesignclass: Define column name mappings once and reuse across all TTE functions -
TTETrialclass: Fluent method chaining with workflow state tracking -
tte_design()/tte_trial(): Constructor functions for the S7 classes -
tte_match(),tte_expand(),tte_collapse(),tte_ipw(): S7 methods for data preparation -
tte_prepare_outcome(),tte_ipcw(): Outcome-specific per-protocol analysis -
tte_weights(),tte_truncate(): Weight combination and truncation -
tte_rbind(): Combine batched trial objects -
tte_extract(),tte_summary(): Access data and diagnostics -
tte_table1(),tte_rates(),tte_irr(),tte_km(): Analysis and visualization
-
Breaking changes
-
REMOVED: Deprecated S7 methods replaced by
tte_prepare_outcome():-
tte_tte(): Usette_prepare_outcome()which computesweeks_to_eventinternally -
tte_set_outcome(): Usette_prepare_outcome(outcome = "...")instead -
tte_censoring(): Usette_prepare_outcome()which handles censoring internally
-
swereg 26.1.30
New features
-
NEW: Target trial emulation weight functions for causal inference in observational studies:
-
tte_calculate_ipw(): Calculate stabilized inverse probability of treatment weights (IPW) for baseline confounding adjustment using propensity scores -
tte_calculate_ipcw(): Calculate time-varying inverse probability of censoring weights (IPCW) for per-protocol analysis using GAM or GLM -
tte_identify_censoring(): Identify protocol deviation and loss to follow-up for per-protocol analysis -
tte_combine_weights(): Combine IPW and IPCW weights for per-protocol effect estimation -
tte_truncate_weights(): Truncate extreme weights at specified quantiles to reduce variance
-
-
NEW: Target trial emulation data preparation functions:
-
tte_match_ratio(): Sample comparison group at specified ratio (e.g., 2:1 unexposed to exposed) -
tte_collapse_periods(): Collapse fine-grained time intervals (e.g., weekly) to coarser periods (e.g., 4-week) -
tte_time_to_event(): Calculate time to first event for each trial/person
-
swereg 25.12.24
API changes
-
SIMPLIFIED: Removed
validate_source_column()requirement fromadd_diagnoses(),add_operations(),add_icdo3s(),add_snomed3s(), andadd_snomedo10s():- The
sourcecolumn is no longer required in diagnosis data - To track diagnoses by source (inpatient/outpatient/cancer), filter the dataset externally before calling
add_diagnoses() - See
?add_diagnosesfor the recommended pattern
- The
New features
-
NEW:
any_events_prior_to()function for survival analysis:- Checks if any TRUE values exist in a preceding time window (excludes current row)
- Useful for determining if an event occurred in a prior time period
- Default window of 104 weeks (~2 years) with customizable size
- Complements
steps_to_first()for comprehensive time-to-event analysis
-
ENHANCED:
steps_to_first()function improvements:- Renamed parameter from
windowtowindow_including_wk0for clarity - Default window is now 104 (inclusive of current week)
- Added
@family survival_analysistag and cross-reference toany_events_prior_to()
- Renamed parameter from
Bug fixes
- FIXED: Added slider package to Imports in DESCRIPTION to fix R CMD check warning about undeclared import
Data
-
BREAKING: Replaced separate
fake_inpatient_diagnosesandfake_outpatient_diagnoseswith unifiedfake_diagnosesdataset:- New
SOURCEcolumn identifies data origin: “inpatient”, “outpatient”, or “cancer” - ~2000 inpatient records, ~2000 outpatient records, ~1000 cancer records
- Cancer records always have populated
ICDO3codes - Enables testing of source-based filtering and validation
- New
-
ENHANCED: Added ICD-O-3 and SNOMED-CT columns to fake diagnosis data:
-
ICDO3: ICD-O-3 morphology codes (always populated for cancer source) -
SNOMED3: SNOMED-CT version 3 codes -
SNOMEDO10: SNOMED-CT version 10 codes
-
swereg 25.12.6
New features
-
NEW:
steps_to_first()function for survival analysis:- Calculates the number of steps (e.g., weeks) until the first TRUE value in a forward-looking window
- Useful for time-to-event calculations in longitudinal registry data
- Default window of 103 weeks (~2 years) with customizable size
- Returns NA if no event occurs within the window
Bug fixes
-
CRITICAL: Fixed
add_snomed3s()andadd_snomedo10s()calling non-existent internal functions- Both functions now correctly call
add_diagnoses_or_operations_or_cods_or_icdo3_or_snomed() - These functions would have caused runtime errors before this fix
- Both functions now correctly call
-
FIXED: Removed erroneous
icdo10column references fromadd_diagnoses():- ICD-O only has editions 1, 2, and 3 (not 10)
- ICD-O-3 codes should be handled via the dedicated
add_icdo3s()function
-
FIXED: Added
icd7*andicd9*columns to diagnosis search inadd_diagnoses():- Historical ICD-7 and ICD-9 columns are now properly searched when
diag_type = "both" - Validation and helper function now consistent
- Historical ICD-7 and ICD-9 columns are now properly searched when
-
FIXED: Corrected error messages in
add_icdo3s(),add_snomed3s(), andadd_snomedo10s():- Messages now correctly reference the appropriate data types instead of “operation data”
Documentation
-
ENHANCED:
add_diagnoses()documentation now clearly lists which diagnosis columns are searched:- When
diag_type = "both":hdia,dia*,ekod*,icd7*,icd9* - When
diag_type = "main":hdiaonly
- When
swereg 25.8.19
CRAN Submission Preparation
-
CRAN READY: Package prepared for CRAN submission with comprehensive compliance improvements:
- Fixed DESCRIPTION file author field duplication issue
- Updated .Rbuildignore to exclude all development files (docs/, .git/, .Rhistory, etc.)
- Removed non-portable files (@eaDir directories, .DS_Store files)
- Added missing global variable declarations to prevent R CMD check warnings
- Verified URL consistency between DESCRIPTION and package startup messages
-
OPTIMIZED: Vignette structure significantly improved for CRAN submission:
- Reduced total vignette content by 31% (626 lines removed)
- Condensed cookbook-survival-analysis.Rmd (removed verbose descriptive statistics and redundant sections)
- Simplified skeleton2-clean.Rmd (removed duplicated skeleton1_create workflow)
- Streamlined skeleton3-analyze.Rmd (removed redundant data loading and best practices sections)
- Fixed all vignette build errors by ensuring consistent data variable availability
- All vignettes now compile successfully and use package synthetic data consistently
- VALIDATED: All examples are runnable using package fake data - no \dontrun sections without justification
Code Quality Improvements
-
CONSISTENCY: Fixed date_columns parameter usage throughout package:
- Updated all vignettes to use lowercase date_columns parameters (e.g., “indatum” instead of “INDATUM”)
- Added warning to make_lowercase_names() function when uppercase date_columns are provided
- Enhanced documentation to clarify that date_columns should use lowercase names
- Improved user experience with clear guidance and automatic handling of uppercase inputs
-
ELEGANCE: Enhanced vignette code patterns for better readability:
- Replaced verbose data() loading patterns with elegant pipe syntax
- Updated all data loading to use swereg::fake_* |> copy() |> make_lowercase_names() pattern
- Eliminated clumsy multi-step data preparation code throughout vignettes
- Improved code flow and professional appearance of package examples
- VERIFIED: Package builds successfully with R CMD build and passes CRAN compliance checks
- CONFIRMED: inst/ directory contains only files referenced by package functions
swereg 25.7.30
New Features
-
NEW:
make_rowind_first_occurrence()helper function for rowdep → rowind transformations:- Simplifies the common pattern of creating row-independent variables from first occurrence of conditions
- Automatically handles temp variable creation and cleanup
- Uses
first_non_na()for robust aggregation across all variable types - Includes comprehensive input validation and clear error messages
-
NEW: “Understanding rowdep and rowind Variables” vignette:
- Explains the fundamental distinction between row-dependent and row-independent variables
- Demonstrates common transformation patterns with practical examples
- Shows integration with the swereg workflow (skeleton1_create → skeleton2_clean → skeleton3_analyze)
- Includes best practices for longitudinal registry data analysis
swereg 25.7.16
New Swedish Date Parsing and Enhanced Data Cleaning
-
NEW:
parse_swedish_date()function for handling Swedish registry dates with varying precision:- Handles 4-character (YYYY), 6-character (YYYYMM), and 8-character (YYYYMMDD) formats
- Automatically replaces “0000” with “0701” and “00” with “15” for missing date components
- Supports custom defaults for missing date parts
- Includes comprehensive error handling and vectorized processing
-
ENHANCED:
make_lowercase_names()now supports automatic date cleaning:- New
date_columnparameter to specify which column contains dates - Automatically creates cleaned ‘date’ column using
parse_swedish_date() - Works with both default and data.table methods
- Maintains backward compatibility with existing code
- New
-
ENHANCED: All
add_*functions now require cleaned date columns:-
add_diagnoses(),add_operations(),add_rx(),add_cods()expect ‘date’ column - Clear error messages guide users to use
make_lowercase_names(data, date_column = "...") - Improved validation ensures data preprocessing consistency
-
-
ENHANCED:
create_skeleton()now includespersonyearscolumn:- Annual rows (is_isoyear==TRUE) have personyears = 1
- Weekly rows (is_isoyear==FALSE) have personyears = 1/52.25
- Facilitates person-time calculations for survival analysis
-
IMPROVED: Survival analysis cookbook vignette updated:
- Uses weekly data instead of yearly data for more precise analyses
- Age calculation based on isoyearweeksun instead of isoyear
- Includes person-time in descriptive statistics
- Demonstrates proper use of new date cleaning workflow
Enhanced error handling and validation
-
ENHANCED: Comprehensive input validation for all
add_*functions:-
add_onetime(): Validates skeleton structure, ID column exists, checks for ID matches -
add_annual(): Validates isoyear parameter, checks skeleton year coverage -
add_diagnoses(): Validates diagnosis patterns, checks for diagnosis code columns -
add_operations(): Validates operation patterns, checks for operation code columns -
add_rx(): Validates prescription data structure, checks source columns -
add_cods(): Validates death data structure, checks cause of death columns
-
-
IMPROVED: User-friendly error messages with specific guidance:
- Clear indication when
make_lowercase_names()is forgotten - Helpful suggestions for column naming issues
- Informative ID mismatch diagnostics with sample values
- Clear indication when
- NEW: Internal validation helper functions for consistent error handling
- ADDED: Input validation for pattern lists, data structures, and parameter ranges
New cookbook documentation
-
NEW: Comprehensive survival analysis cookbook (
cookbook-survival-analysis.Rmd):- Complete workflow from raw data to Cox proportional hazards model
- Time-varying covariates (annual income) with heart attack outcome
- Handles common challenges: missing data, multiple events, competing risks
- Performance tips for large datasets
- Practical solutions for real-world registry analysis
-
ENHANCED: Updated
_pkgdown.ymlwith new “Cookbooks” section -
ADDED:
survivalpackage to Suggests dependencies
swereg 25.7.16
Major documentation restructuring
-
RESTRUCTURED: Complete vignette reorganization for clear learning progression:
- NEW “Skeleton concept” vignette: Conceptual foundation explaining the skeleton approach without technical implementation
- “Building the data skeleton (skeleton1_create)”: Pure data integration focus - raw data to time-structured skeleton
- “Cleaning and deriving variables (skeleton2_clean)”: Pure data cleaning and variable derivation focus
- “Production analysis workflows (skeleton3_analyze)”: Memory-efficient processing and final analysis datasets
- IMPROVED: Clear separation of concerns with focused, single-purpose tutorials
- ENHANCED: Systematic learning progression from concept to implementation to production
- UPDATED: _pkgdown.yml structure with logical vignette grouping
- PRESERVED: All existing technical content while improving organization
Content improvements
- NEW: Comprehensive conceptual introduction based on presentation content
- IMPROVED: Each vignette builds systematically on the previous one
- ENHANCED: Better explanation of three types of data integration (one-time, annual, event-based)
- CLARIFIED: Production workflow patterns with memory-efficient batching strategies
- STANDARDIZED: Consistent academic tone and sentence case throughout
swereg 25.7.15
Documentation and presentation improvements
-
STANDARDIZED: Changed all titles and headings to normal sentence case throughout:
- Vignette titles: “Basic Workflow” → “Basic workflow”, “Complete Workflow” → “Complete workflow”, etc.
- README.md section headings: “Core Functions” → “Core functions”, “Data Integration” → “Data integration”, etc.
- NEWS.md section headings: “Vignette Restructuring” → “Vignette restructuring”, etc.
- CLAUDE.md section headings: “Project Overview” → “Project overview”, “Development Commands” → “Development commands”, etc.
- IMPROVED: Consistent normal sentence case for better readability and less formal appearance
- SIMPLIFIED: Removed subtitle text after colons in vignette titles for cleaner presentation
-
ENHANCED: Improved Core Concept section in basic workflow vignette with clear explanation of three data types:
- One-time data (demographics): Added to all rows for each person
- Annual data (income, family status): Added to all rows for specific year
- Event-based data (diagnoses, prescriptions, deaths): Added to rows where events occurred
-
CLARIFIED: Step 1 documentation now properly explains all skeleton columns including
isoyearweeksun - VERIFIED: All vignettes compile successfully with improved content
Major documentation and vignette reorganization
-
RESTRUCTURED: Complete vignette reorganization with improved naming and content flow:
-
swereg.Rmd→basic-workflow.Rmd: Focused introduction to skeleton1_create -
advanced-workflow.Rmd→complete-workflow.Rmd: Two-stage workflow (skeleton1_create + skeleton2_clean) -
memory-efficient-batching.Rmd: Maintained as comprehensive three-stage workflow guide
-
- IMPROVED: Eliminated content redundancy between vignettes for clearer learning progression
- ENHANCED: Updated _pkgdown.yml configuration to reflect new vignette structure
Function documentation improvements
-
ENHANCED: Comprehensive documentation improvements for all exported functions:
- Added @family tags for logical grouping (data_integration, skeleton_creation, data_preprocessing)
- Added @seealso sections with cross-references to related functions and vignettes
- Replaced placeholder examples with runnable code using synthetic data
- Improved parameter documentation with detailed descriptions and expected formats
- Enhanced return value documentation with explicit side effects description
- STANDARDIZED: Consistent academic tone throughout all documentation
Professional presentation updates
- IMPROVED: Removed informal elements and adopted academic tone across all documentation
- UPDATED: Changed terminology from “fake data” to “synthetic data” throughout
- ENHANCED: More professional language in README.md and vignettes
- STANDARDIZED: Consistent formal tone appropriate for scientific software
swereg 25.7.1
Vignette restructuring
-
RESTRUCTURED: Reorganized vignettes for clearer learning progression:
-
swereg.Rmd: Clean skeleton1_create tutorial using full datasets (removed subset filtering) -
advanced-workflow.Rmd: Focused skeleton1→skeleton2 workflow (removed batching and skeleton3 content) -
memory-efficient-batching.Rmd: NEW comprehensive batching vignette with complete skeleton1→skeleton2→skeleton3 workflow for large-scale studies
-
- IMPROVED: GitHub Actions workflow optimization with dependency caching and binary packages for faster CI/CD
Batching vignette fixes
-
FIXED: Updated memory-efficient-batching vignette with production-ready improvements:
- Replace
split()withcsutil::easy_splitfor better batch handling - Replace
saveRDS/readRDSwithqs::qsave/qreadfor 2-10x faster file I/O - Fix skeleton3_analyze to properly aggregate weekly→yearly data using
swereg::max_with_infinite_as_na - Remove incorrect
is_isoyear == TRUEfilter in skeleton3_analyze - Fix analysis results to avoid NaN outputs in treatment rate calculations
- Add explanations for weekly→yearly data aggregation and qs package performance benefits
- Replace
New features
-
NEW: Added
isoyearweeksunvariable tocreate_skeleton()function - provides Date representing the Sunday (last day) of each ISO week/year for easier date calculations - NEW: Updated package logo
-
IMPROVED: Updated all vignettes to not assume swereg is loaded - all functions use
swereg::prefix anddata()calls usepackage="swereg"argument -
IMPROVED: Updated function documentation to clarify that pattern matching functions (
add_diagnoses,add_cods,add_rx) automatically add “^” prefix - users should NOT include “^” in their patterns -
NEW: Added comprehensive fake Swedish registry datasets for development and vignettes:
-
fake_person_ids: 1000 synthetic personal identifiers -
fake_demographics: Demographics data matching SCB format -
fake_annual_family: Annual family status data -
fake_inpatient_diagnosesandfake_outpatient_diagnoses: NPR diagnosis data with ICD-10 codes -
fake_prescriptions: LMED prescription data with ATC codes and hormone therapy focus -
fake_cod: Cause of death data
-
-
NEW: Added two comprehensive vignettes:
-
swereg.Rmd: Basic skeleton1_create workflow tutorial -
advanced-workflow.Rmd: Complete 3-phase workflow (skeleton1 → skeleton2 → skeleton3)
-
- NEW: Replaced magrittr pipe (%>%) with base pipe (|>) throughout codebase
- NEW: Added memory-efficient batched processing examples for large registry studies
Bug fixes
-
CRITICAL: Fixed incorrect variable names in
fake_coddataset - changed from non-Swedishunderlying_cod/contributory_cod1/contributory_cod2to correct Swedish registry namesulorsak/morsak1/morsak2 - VERIFIED: Confirmed all fake datasets use correct Swedish registry variable name conventions
- VERIFIED: All ICD-10 and ATC codes in fake datasets are properly formatted and realistic
Documentation improvements
- BREAKING: Fixed incorrect function descriptions that were copied from another package
-
NEW: Added comprehensive roxygen2 documentation for all exported functions:
-
add_onetime(): Documents merging one-time/baseline data to skeleton -
add_annual(): Documents merging annual data for specific ISO years -
add_cods(): Documents cause of death analysis with ICD-10 codes -
add_diagnoses(): Documents diagnosis analysis with main/secondary diagnoses -
add_operations(): Documents surgical operation analysis including gender-affirming procedures -
add_rx(): Documents prescription drug analysis with ATC/product codes -
create_skeleton(): Documents longitudinal skeleton creation with detailed return structure -
make_lowercase_names(): Documents generic function with S3 methods - The 2023 study
add_lmed()entry point: Documents a study-specific LMED function
-
- NEW: Added documentation for all helper functions:
-
NEW: Added
@paramdescriptions for all function parameters -
NEW: Added
@returndescriptions explaining function outputs -
NEW: Added
@exampleswith practical usage demonstrations -
NEW: Added
@detailsand@notesections for complex functions -
IMPROVED: Used proper roxygen2 practices including
@rdnamefor S3 methods and@seealsocross-references
