Chained kit replacement with the SAS hash object
Resolving a replacement chain of unknown depth in one pass of a DATA step: why an in-memory hash is the only structure that fits this shape of problem, what the step guarantees, and what it does when the extract is not clean. A self-contained Base SAS check ships with the note — one fixture, eleven assertions.
Study identifiers, library names, paths and program headers have been removed or replaced by placeholders. The listings are self-contained and are referred to by step and by marker rather than by line number, so they can be lifted into another program without editing references. The check that accompanies the note is synthetic in the same way: it reads no study data.
This is the kit-replacement derivation only — the split of the IRT feed, the chain walk, the collapse to one record per subject and visit, and the join back to the clinical records. It is not a general hash tutorial and not a full SDTM EC mapping specification.
1The problem the code solves
A linked list stored as rows, and the identifiers that have to come out of the far end of it.
ECREFID, ECLOT and BATCHNUM must carry the final kit of a visit, not the kit recorded at that visit, because a kit can be replaced one or more times before or during administration. The IRT extract holds both facts in one table: the kit used, and the kit it was replaced by.
That makes the input a singly linked list. Each row is a node, kitnumrp is the pointer, and the terminal node is the row whose pointer is missing. Walking it needs random access to the node table from inside a loop, which is what a hash object provides and a merge does not.
1.1 Terms
| Variable | Role in the pattern |
|---|---|
scn_num | Subject identifier. Half of the hash key, and the join key back to SUBJID. |
vis_type | Visit label. Drives the split, and is the join key back to VISIT. |
kit_tyds | Kit type. Part of the collapse key; a visit may legitimately carry several kit types. |
kit_num | Kit number. The second half of the hash key, and the value the walk rewrites. |
kitnumrp | “Kit number replacement”: the kit that this kit was replaced by, missing on a terminal kit. This is the pointer. |
lot_num, btch_num | Lot and batch of the row's kit. Carried as hash payload so the terminal hop supplies them. |
Three properties are needed at once: re-probe the same table with a different key inside one observation, leave the driving table in its own order, and cope with a chain depth that is unknown until the data is read. A merge or a join satisfies none of the three.
kitnumrp and the lot/batch payload in the PDV, which is what advances the walk.2Hash essentials
The minimum that has to be understood before the code in section 3 reads as obvious.
2.1 Skeleton
if _n_ = 1 then do; (1)
declare hash h(dataset: "irt_rp",
duplicate: "error"); (2)
h.definekey("scn_num","kit_num"); (3)
h.definedata("kitnumrp","lot_num","btch_num"); (4)
h.definedone(); (5)
end;
...
rc = h.find(); (6)
- The guard.
declareis a compile-time statement, but the object is created and the table read when the step executes: withoutif _n_ = 1both are redone on every iteration. - Declare, and point at the source table. A data set name in the
dataset:tag is loaded atdefinedone(), and the rule for a repeated key belongs here too — Listing 3 depends on it. - The key: the variables that form the address. Define the key first; lookup compares the current values of these PDV variables.
- The data: the payload a successful probe writes back. A variable may be both key and data, and then matches by value and is overwritten as payload.
- Ends the definition and triggers the load. Every name in
definekeyordefinedatamust exist in the PDV by this point. - The probe. With no arguments it works on the PDV variables of steps 3 and 4; a driver can be passed with the
key:tag.
2.2 The one design decision that carries the whole pattern
In this program kitnumrp is declared as data even though it also drives the loop, and that is load-bearing. A successful find() writes the payload straight back into the PDV, so the loop control variable advances by itself, and the terminal hop's lot_num and btch_num arrive in the same write — which is what keeps ECLOT and BATCHNUM consistent with the terminal kit.
2.3 Argument tags worth knowing
| Tag | Effect |
|---|---|
dataset: "name" | Loads the table at definedone(); data set options such as where= may be embedded in the string. |
duplicate: "replace" | "error" | The default keeps the first row for a repeated key and writes nothing to the log; "replace" keeps the last; "error" stops the load. [doc] |
multidata: "yes" | Several payloads per key, walked with find_next. For a kit that legitimately has more than one replacement record. |
ordered: "yes" | Keeps the entries in key order, which an iterator requires. |
2.4 Methods used, and the ones to reach for next
| Method | Purpose |
|---|---|
definekey / definedata / definedone | Build the object. The order is fixed. |
find() | Look up the current key; on a hit, copy the payload into the PDV. |
check() | The same test without the copy, for an existence test where the old values must survive. |
add() / replace() / remove() | Maintain the object at run time instead of loading it from a table. |
num_items | Attribute, not a method: how many entries there are. Worth a line in the log as a load check. |
0 means success and a non-zero value means failure — the opposite of what most people expect. Assign the return code and test it: a failure whose code was not assigned writes an error to the log, while one that was assigned stays silent until you look. [doc]
2.5 Pitfalls
| Symptom | Cause and remedy |
|---|---|
NOTE: Variable X is uninitialized | The compiler cannot see the assignments the hash performs, so a key or data variable with no assignment anywhere in the step is reported. Give it a length and an initial assignment, or call missing. |
| Extra columns in the output data set | Variables created for the hash live in the PDV and are written out with everything else. Add them to a drop unless they are deliverables. |
| Silent wrong lookup, no message | A duplicate key was loaded and the first row won. Decide the rule and enforce it, or use duplicate: "error". |
| Every observation returns the same value | The key variable is not the one the set updates, or the driver was passed as a literal. Names resolve case-insensitively, so it is nearly always a wrong name rather than a wrong case. |
The payload of a probe is the loop's progress: declare kitnumrp as data and the walk advances without a single assignment of its own — but then the loop can only be stopped by the pointer, never by the index.
3Reading the code
Five steps in the order they run: split the feed, reduce it to one row per kit, walk the chain, collapse the result back to one record per visit, and join it to the clinical records.
Listing 1 — split the feed
data irt_base irt_rp; (1)
set raw.irt_kit; (2)
vis_type = upcase(vis_type);
if vis_type = "DISCONTINUE" then vis_type = "END OF TREATMENT";
keep scn_num kit_tyds kit_num lot_num vis_type btch_num kitnumrp;
if vis_type = "KIT REPLACEMENT" then output irt_rp;
else if not missing(kit_tyds) then output irt_base; (3)
run;
- Two output data sets from one pass; the explicit
outputstatements mean each input row reaches at most one of them. - The single IRT extract, holding both kinds of row, distinguished by
vis_type. - Case normalisation before the split, so the comparison below it is reliable, and a row with no kit type is dropped here. This is also the first half of the visit vocabulary the join needs; the clinical side keeps its own copy, outside this step.
Listing 2 — one row per kit, key and payload together
/* One row per kit for hash load. The BY list carries the payload as well, so a
conflicting pair of replacement rows survives this step, and the load -- which
is declared duplicate:"error" below -- refuses it instead of silently keeping
the first row it happened to read. */
proc sort data = irt_rp nodupkey;
by scn_num kit_num kitnumrp lot_num btch_num; (4)
run;
- The deduplication and the guardrail are one decision. The
bylist is the hash key (scn_num,kit_num) plus the payload (kitnumrp,lot_num,btch_num), so it collapses only rows identical on all five fields — one event recorded twice — and leaves a genuine conflict for the load to refuse. §5.4 works through both, S17 and S14.
Listing 3 — walk the replacement chain to its terminal kit
data irt_chase;
if _n_ = 1 then do;
declare hash h(dataset: "irt_rp", duplicate: "error"); /* F3 */ (5)
h.definekey("scn_num","kit_num");
h.definedata("kitnumrp","lot_num","btch_num"); (6)
h.definedone();
end;
set irt_base; (7)
length chain_status $10; (8)
prev_kit = kit_num; /* F1 */
chain_status = '';
do i = 1 to 100 while (not missing(kitnumrp)); (9)
kit_num = kitnumrp; (10)
rc = h.find(); (11)
if rc ne 0 then do;
chain_status = 'DANGLING';
kit_num = prev_kit; /* F1 */
leave;
end;
prev_kit = kit_num;
end;
if chain_status = '' then /* F2 */
chain_status = ifc(missing(kitnumrp), 'RESOLVED', 'UNRESOLVED');
if chain_status ne 'RESOLVED' then
put "WARNING: kit chain " chain_status= scn_num= vis_type= kit_tyds= kit_num=;
drop i rc prev_kit;
run;
- Declared and loaded once, from the deduplicated table, with the duplicate rule attached.
kitnumrpis payload, not just a pointer: a hit overwrites it with the next link, so the loop advances by the lookup itself, and the terminal hop'slot_numandbtch_numarrive in the same write.- The driving table, streamed in its own order; nothing has to be sorted by the lookup key.
- The status column, kept in the output so that a
proc freqover it replaces grepping the log and a QC step can fail on it. - Two termination conditions in one statement: a
whiletest evaluated before every iteration including the first, and a hard budget of 100 probes. - The pointer moves before the probe, so the row's own kit number is replaced by the recorded replacement.
- The probe. On a hit the payload lands in the PDV; on a miss nothing is written back, which is why the miss branch restores the previous kit itself.
Trace 1 — what one three-link walk does to the PDV
The table follows one visit-level row whose kit 1001 was replaced by 1002, then 1003, then 1004. It is a reading of the listing above, not a log extract: the step writes to the log only when a chain fails to resolve.
| i | Before the probe | After find() | Payload in the PDV |
|---|---|---|---|
| 1 | kit_num = 1002 | rc = 0 | kitnumrp → 1003, lot LOT-B, batch B02 |
| 2 | kit_num = 1003 | rc = 0 | kitnumrp → 1004, lot LOT-C, batch B03 |
| 3 | kit_num = 1004 | rc = 0 | kitnumrp → missing, lot LOT-D, batch B04 |
After iteration 3 the while test fails and the row is left with kit_num = 1004 and that kit's lot and batch, because one probe wrote all three back. Three probes for a three-link chain, and the first was aimed at the pointer on the base row rather than at the row's own kit number.
Listing 4 — collapse to one record per visit
proc sort data = irt_chase;
by scn_num vis_type kit_tyds kit_num; (12)
run;
data irt_claps;
set irt_chase;
by scn_num vis_type kit_tyds kit_num;
length refid $200; (13)
retain refid;
if first.vis_type then refid = strip(put(kit_num, best.));
else refid = strip(refid) || ', ' || strip(put(kit_num, best.));
if last.vis_type; (14)
run;
- Sort order decides both the grouping and the order inside
refid: kit type, then kit number — not the order the kits were administered in (F4). - The accumulator, 200 characters, far more than a visit's handful of kits needs, so it does not truncate in practice (F4).
last.vis_typeturns a visit's kit rows into one output row: the reference list, plus the lot and batch of the last row in sort order.
Listing 5 — back to the clinical records
proc sql;
create table ec_ext as
select a.*, c.kit_tyds, c.kit_num, c.refid, c.lot_num, c.BTCH_NUM, c.vis_type
from ec_dy as a left join irt_claps as c
on a.subjid = c.scn_num and a.visit = c.vis_type; (15)
quit;
data ec_asgn;
length domain $2 eclnkid $20 eclot ecrefid $200;
set ec_ext;
domain = 'EC';
if ecstdtc ne '' then
eclnkid = strip(compress(ecstdtc,"-:"))||'-'||upcase(substr(ectrt,1,1));
else eclnkid = '';
ecrefid = strip(refid); (16)
eclot = strip(lot_num);
BATCHNUM = strip(BTCH_NUM);
if not missing(ecstdtc) then epoch = 'TREATMENT';
run;
- A left join on subject and visit. The key is numeric on one side and character on the other in some extracts, and a type mismatch makes the join miss silently: the visit survives with missing kit information and nothing in the log says so.
- The three identifiers come straight from the collapsed record.
ECREFIDlists every kit the visit resolved to, whileECLOTandBATCHNUMcome from the one row the collapse kept (F4).
4Why a hash, and not a merge
The comparison that decides the design, against the three properties this problem actually needs.
| Approach | Re-probe one observation's key against the same table | Needs the driving table sorted by the lookup key | Unknown depth, single pass |
|---|---|---|---|
merge / set with by |
No — one positional match per observation | Yes — both inputs must be sorted on the same keys | No — one hop per merge, so depth must be known |
proc sql self-join |
No | No | No — one hop per join, and SAS SQL has no recursive CTE |
Format or put() lookup |
No — the value is fixed when the format is built | No | No — single hop, and the format cannot be updated at run time |
set with key= (index) |
Yes | No | Yes — but every probe needs the index and costs more |
| Hash object | Yes | No | Yes |
The walk is a pointer chase of unknown length, and the number of hops is a property of the data rather than of the program: no fixed number of merges or joins can express it. A merge-based solution would have to keep joining until nothing changes, re-scanning and re-sorting on every pass, and it would destroy the subject-and-visit order the rest of the program depends on. The hash object is the only structure that lets a DATA step ask a question of a lookup table from inside its own loop.
Two smaller arguments point the same way. Sorting: the visit-level feed's natural order is by subject and visit, and the collapse depends on it, while a merge would impose its own sort and a second re-sort afterwards. Cost: the replacement table is small and read thousands of times, which is the case a hash is built for.
When a merge is still the better tool. If the identifier needs one hop — a single m:1 enrichment from a small reference table — a merge or a plain left join is shorter and easier for a second programmer to check. The hash earns its complexity only when the lookup repeats, with a key that changes during the observation. Either way, keep the small side in the hash: loading the visit-level feed would make memory grow with the study.
5Design and correctness
What the step guarantees, what it does when the input is not clean, and what it does not cover.
Every claim below names the lines that carry it and the case that shows it. The cases are the fixture in §7.2, printed there as kit_chain_check.sas holds it, and the check asserts the result column of the matrix row by row. The expected results in §5.2 are therefore the specification the run tests, not a summary of a log: no SAS session was available where this note was written. §7.5 lists the three behaviours that only a real session settles.
5.1 What the step guarantees
| Guarantee | Where it comes from |
|---|---|
| G1 The hash is loaded with one row per key, or the step does not run. | The by list of the deduplication carries the key and the payload, so a conflict survives it (Listing 2); the object is declared duplicate: "error", so the conflict stops the load (Listing 3, marker 5). S14 is that case. |
| G2 A probe that hits advances the kit, the lot and the batch together; a probe that misses changes none of them. | All three are hash payload, so one find() writes them back together, and the miss branch restores the previous kit (Listing 3, markers 6 and 11). S02 and S06 show the two directions. |
| G3 The walk cannot hang, and it cannot stop early in silence. | The budget of 100 probes bounds the loop, and chain_status is computed after the loop from the pointer, which is missing only when the walk reached a terminal record. S03 and S07 are the two ends of that. |
| G4 Every unresolved row is labelled and logged; no resolved row is. | chain_status is kept in the output and the put is gated on it. The check compares the status of every visit row, so an edit that quietly resolved a dangling row would fail it. |
5.2 Scenario matrix
Each row names the case as the fixture records it — the kit numbers in the second column are the rows printed in §7.2 — and the check asserts the result column of every one of them. Hops is how many links the visit-level row walks, which is the number of find() calls it makes. resolved is the intended outcome; flagged means the row left with a status other than RESOLVED and one line in the log, so QC has to count it; data issue means the extract itself is wrong and the step's job is to refuse it.
| ID | Case as tested | Result — kit / lot / batch | Hops | Verdict |
|---|---|---|---|---|
| S01 | 1001 terminal: no replacement recorded | 1001 / LOT-A / B01 — unchanged | 0 | resolved |
| S02 | 1001 → 1002: one replacement | 1002 / LOT-B / B02 | 1 | resolved |
| S03 | 1001 → 1002 → 1003 → 1004: three replacements, the most the data has shown | 1004 / LOT-D / B04 | 3 | resolved |
| S05 | 1001 → 9001, and no row describes 9001 | 1001 / LOT-A / B01 — the kit the row started from, whole; one WARNING line | 1 | flagged |
| S06 | 1001 → 1002 → 1099, and no row describes 1099 | 1002 / LOT-B / B02 — the kit from hop 2, with its own lot and batch; one WARNING line | 2 | flagged |
| S07 | 1001 → 1002 → 1003 → 1002: a cycle | 1003 / LOT-C / B03 — well formed and wrong; one WARNING line | 100 | flagged |
| S09 | the row's own pointer is empty, although 1001 was replaced | 1001 / LOT-A / B01 — unchanged; the key is never consulted | 0 | resolved |
| S10 | 1001 → 1002, and the replacement table is empty | 1001 / LOT-A / B01 — restored, not the two halves of a pair; one WARNING line | 1 | flagged |
| S11 | the same kit number under two subjects, replaced differently | subject 101 → 1002 / LOT-T1 / BT1; subject 202 → 1002 / LOT-T2 / BT2 — no cross-talk | 1 | resolved |
| S12 | the visit label arrives as discontinue, the replacement as kit replacement | 1002 / LOT-B / B02 under END OF TREATMENT — normalised before the split | 1 | resolved |
| S14 | two rows claim kit 1002 with different lots: a duplicate in the extract | load refused — ERROR: Duplicate key (101, 1002), no output data set | — | data issue |
| S17 | kit 1002 recorded twice, byte-identical | 1003 / LOT-C / B03 — three rows in, two keys loaded | 2 | resolved |
| S15 | 2,000 subjects × 4 visits, three replacements each, every visit its own kit block | every row lands on its terminal kit, on a table of 24,000 replacement rows | 3 | resolved |
The rows down to S11 are the walk. S12 is the split's normalisation, S14 and S17 are the deduplication in front of the load, and S15 is a volume check: cost is flat in chain depth and nothing exhausts the probe budget.
Four cases that used to sit in this matrix are gone, all for the same reason — they were not about this step. Eight hops: S03 already shows the depth is open, and three replacements is the most the extract has shown. A kit whose pointer names itself: S07's cycle is the same failure. A row with no kit type (SCREENING in the raw IRT file) and a visit label the two sides of the join spell differently: both are settled before this step sees the data, one by the extract, the other by the clinical-side mapping. Testing them here would have made the matrix longer without telling a reader anything about the walk.
5.3 When the chain does not resolve
Two shapes of bad input reach the walk, and they fail differently. Neither is visible unless the status column is read: both leave an output that looks like any other row.
The pointers, and what “dangling” means
The extract carries one pointer column, kitnumrp: the kit that this kit was replaced by, missing on a terminal kit. Listing 1 keeps it in both of its outputs. The step adds one pointer of its own, prev_kit, in the PDV, and drops it again with rc and the loop index, so the only pointer that survives into irt_chase is kitnumrp — kept deliberately, because the status test reads it. The join in Listing 5 selects named columns, so no pointer reaches the SDTM variables at all: ECREFID, ECLOT and BATCHNUM carry the terminal kit, and nothing in the output records how many hops it took to get there.
A pointer is dangling when the kit it names has no row in the replacement table. That table is the only place a kit's successor is recorded, so such a pointer can never be followed and the walk stops one hop short of a terminal kit. It happens in two positions: on the first hop, where the visit row's own pointer names a kit the extract never described (S05: 1001 → 9001), and mid-chain, where a replacement row does (S06: 1002 → 1099). A dangling pointer is not a cycle: a cycle keeps resolving, it simply never arrives at a terminal kit (S07).
kit_num = kitnumrp; runs before the probe, and a miss writes nothing back. Without the miss branch the row would leave with a kit number taken from the replacement record and a lot and batch taken from the row it replaced — a combination that exists in no record and that no ECREFID / ECLOT cross-check can reconcile. S05, S06 and S10 are that case, S10 with an empty table.
How the step handles it. prev_kit holds the kit the last successful probe landed on, and a miss restores it, so the three fields still describe one physical kit — the best available answer — and the row carries DANGLING and one WARNING line. The check asserts the restored triple for all three cases, which is what makes the restore a rule rather than a hope.
The budget of 100 probes stops the loop, so the step cannot hang, and it writes no diagnostic of its own beyond the status: S07 makes exactly 100 probes and leaves a kit number that looks like any other. Cyclic replacement data is not hypothetical — it is what a double-entered or partially corrected extract looks like.
How the step handles it. After the loop the pointer is tested, not the loop index: missing(kitnumrp) is true exactly when the walk landed on a terminal record, so UNRESOLVED means the budget ran out. The 100 decides only how long a cyclic chain takes to give up — a real chain is three links at most, and S03 walks three — so the budget never decides an answer. The check asserts the status and the probe count of S07, which is what separates “exhausted the budget” from “stopped early” in the evidence rather than in the prose.
5.4 The duplicate rule, and the deduplication in front of it
Two questions live in the same two statements. The step already sorts the replacement rows with nodupkey, so is duplicate: "error" doing anything — and is the sorting the reason a load ever errors out? Neither, and the by list is where both are decided: it carries the hash key plus the hash payload, so the sort collapses exactly the rows that are identical on all five fields and nothing else. The two cases that separate the outcomes are S17 and S14.
| Case | Rows entering the sort | Keys reaching the load | Outcome |
|---|---|---|---|
| S17 — two rows agree | 3, of which two are byte-identical | 2 | The identical pair is one event recorded twice and collapses. The load proceeds and the answer is 1003 / LOT-C / B03. |
| S14 — two rows disagree | 4, of which two claim the same kit with different successors | 4 | The conflict survives the sort on purpose and the load refuses it: ERROR: Duplicate key (101, 1002). The step stops and no output data set is produced. |
S14 is a data issue, not a defect in the step. Its two rows claim the same kit 1002 with different lots and different successors. In the extract a kit carries its status, and a kit that was assigned was replaced at most once, so at most one of the two rows can describe what happened: the other is a duplicate or a stale row in the IRT extract. What the step owes a reader is to make that visible instead of choosing for them, and that is what the refusal does. The repair belongs upstream, in the extract.
So the sort neither raises the error nor suppresses the rule: it is what gives the rule something to catch. The declaration on its own would be inert, and the measurement is blunt about it — run the same four S14 rows through a by list of the key alone and they load as three keys, no duplicate ever reaches the load, and which of the two conflicting replacements wins is decided by the order the rows happen to arrive in: the same data then answers 1003 / LOT-T3 / BT3 or 1004 / LOT-T4 / BT4. The model beside the check prints both answers from the same fixture.
The by list of the deduplication must contain the key and every payload field the hash carries. Add a field to definedata and the same field has to be added here, or a conflict on it becomes invisible again. A by list that already makes the key unique makes the declaration decorative — and the failure is silent, which is the whole reason this is written down.
A conflict is a hard stop: the DATA step ends, no data set is produced, and the job fails. That severity is intended, because an unresolved conflict must not reach a submission. If the team would rather see the offending rows than fail, detect the conflict before the load, write it to a review data set, and there is then nothing left for duplicate: "error" to do. The check asserts the three row and key counts that decide whether the rule can fire at all; the refusal itself is one log line away, in the block §7.4 describes.
5.5 What the step gets right
- Chain depth is genuinely open. Zero, one and three hops all resolve (S01, S02, S03), and the loop assumes nothing about how many links there are.
- The composite key is necessary, not decorative. S11 gives two subjects the same kit number with different replacements; a key on
kit_numalone would have merged them. - The physical order of the driving table does not matter. The visit-level feed is streamed in whatever order it arrives, and nothing has to be sorted by the lookup key.
- Case and one known alias are handled before the split, so the classification and the join back use the same label (S12).
- Every unresolved row carries both a status and a log line, and no resolved row carries either (S05, S06, S07, S10).
The step's job is the walk, and the walk is sound: the depth is open, the key is composite, order does not matter, and every unresolved row is labelled and counted. What it does not do is police the extract — the load's refusal is what makes a conflict visible, and the repair belongs upstream.
5.6 Defects this step does not fix
One finding is about the collapse rather than the walk. It is recorded here because it is the same class of failure — an answer that is well formed and wrong, with nothing in the log saying so.
The collapse emits one row per subject and visit. refid lists every kit the visit resolved to, while ECLOT and BATCHNUM come from the single last.vis_type row, so a visit that ended on three kits reports one lot and one batch for all three. The check prints that record. The order inside the list is the sort key — kit type, then kit number — so it is grouped by kit type, not by the sequence in which the kits were administered.
This is a specification question rather than a bug, but it is the kind that survives to a review table: a reviewer comparing ECREFID with ECLOT sees a one-to-many relationship that the specification may not have intended. Decide the cardinality, write it down, and if one identifier per kit is wanted, emit one row per kit instead of collapsing.
The accumulator is length refid $200. For five-digit kit numbers that holds 28 entries — floor(202 / 7) — and a visit carries a handful, so the cap is not reachable at these kit counts. It becomes reachable only if the cardinality above is changed to one number per kit for a visit with dozens of them, which is the same decision seen from the other side.
6Reuse checklist
What to change, and what to check before the pattern goes into another program.
- Parameterise the input and the join key. The IRT table name, the subject variable and the visit variable are the only study-specific parts; keep them in one place.
- Confirm the key types match. A numeric key on one side and a character key on the other gives a walk that probes with the wrong values and finds nothing — and a miss is a normal outcome of the loop, so this is the failure most likely to reach production unnoticed.
- Decide the duplicate rule before the first run. If two rows can describe the same kit, encode the rule in a sort column, or use
duplicate: "error"— and check that the deduplication upstream leaves the conflict in place for the load to see. - Keep a status column and count it.
chain_statusturns three log-only outcomes into something a QC step can fail on: a column is countable, log lines are not. - Drop the scratch variables. The hash payload and the pointer live in the PDV and are written out with everything else unless they are dropped deliberately.
- Re-run the check after any change to the split, the key definition or the collapse. The expected values are cheap to maintain and the scenarios are the ones that actually break.
7The check that ships with this note
One self-contained SAS program, the dummy data behind every scenario, and what a passing run prints.
7.1 The verifier
kit_chain_check.sas lives in its own repository, which also carries the fidelity check and the emulation named below. It is Base SAS only — no study macros, no formats, no external libraries, no input files: every row it reads is a datalines line or the output of a loop inside the program itself. It runs the four chain steps over one scenario at a time and asserts what came out. Five parts, in the order they appear:
- Fixture — one pipe-separated line per row of the IRT extract, for the twelve visit-level rows of §5.2. A
.in the last field is a terminal kit. - Expected results — one line per visit-level row: the kit, lot, batch, status and probe count it must resolve to. These are the numbers printed in §5.2, and the fidelity check compares the two.
- The step — Listings 1–4, run once per scenario through
%run_case, plus the S14 precondition and the generated S15. - Comparison — expected against actual on case, subject and visit, five fields per row.
- Regression gate — every assertion appends a row to
work.checks; the log ends with the pass count and the process aborts if any line failed.
The step it runs is the step in Listing 1 to Listing 4, and that is checked rather than claimed: reference-model/code_fidelity.py compares the verifier's copy against the program statement by statement, ignoring comments, case and whitespace, and fails if a single statement is missing. The same script holds this note's listings to the program, and its matrix to the check's expected table. Two differences are expected and named in the file: the split reads the fixture instead of the study extract, and two lines count the probes each row makes, so the probe budget can be asserted rather than assumed.
7.2 The dummy data, case by case
The fixture as it stands in the program, read from the file rather than retyped. The field order is case|rowid|scn_num|kit_num|lot_num|btch_num|vis_type|kit_tyds|kitnumrp. Each case is run on its own, so kit numbers may repeat across scenarios, and the expected result of every line is the matching row of §5.2.
S01|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|.
S02|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|1002
S02|2|101|1002|LOT-B|B02|KIT REPLACEMENT|KIT|.
S03|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|1002
S03|2|101|1002|LOT-B|B02|KIT REPLACEMENT|KIT|1003
S03|3|101|1003|LOT-C|B03|KIT REPLACEMENT|KIT|1004
S03|4|101|1004|LOT-D|B04|KIT REPLACEMENT|KIT|.
S05|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|9001
S05|2|101|1002|LOT-B|B02|KIT REPLACEMENT|KIT|.
S06|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|1002
S06|2|101|1002|LOT-B|B02|KIT REPLACEMENT|KIT|1099
S07|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|1002
S07|2|101|1002|LOT-B|B02|KIT REPLACEMENT|KIT|1003
S07|3|101|1003|LOT-C|B03|KIT REPLACEMENT|KIT|1002
S09|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|.
S09|2|101|1001|LOT-B|B02|KIT REPLACEMENT|KIT|1002
S10|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|1002
S11|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|1002
S11|2|101|1002|LOT-T1|BT1|KIT REPLACEMENT|KIT|.
S11|3|202|2001|LOT-X|B91|CYCLE 1 DAY 1|KIT|1002
S11|4|202|1002|LOT-T2|BT2|KIT REPLACEMENT|KIT|.
S12|1|101|1001|LOT-A|B01|discontinue|KIT|1002
S12|2|101|1002|LOT-B|B02|kit replacement|KIT|.
S14|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|1002
S14|2|101|1002|LOT-B1|B02|KIT REPLACEMENT|KIT|1003
S14|3|101|1002|LOT-B2|B03|KIT REPLACEMENT|KIT|1004
S14|4|101|1003|LOT-T3|BT3|KIT REPLACEMENT|KIT|.
S14|5|101|1004|LOT-T4|BT4|KIT REPLACEMENT|KIT|.
S17|1|101|1001|LOT-A|B01|CYCLE 1 DAY 1|KIT|1002
S17|2|101|1002|LOT-B|B02|KIT REPLACEMENT|KIT|1003
S17|3|101|1002|LOT-B|B02|KIT REPLACEMENT|KIT|1003
S17|4|101|1003|LOT-C|B03|KIT REPLACEMENT|KIT|.
S15 has no fixture line: it is generated, 2,000 subjects by four visits by default, each visit carrying its own block of kit numbers so that one visit's kits cannot collide with another's, and three replacement rows per visit. Its size is the macro parameter vol_subjects at the top of the file; set it to 0 to skip the case altogether. The F4 assertion builds its own three-row group, because it is about the collapse rather than the walk.
7.3 What a passing run prints
Three tables, all from data the program builds: the comparison, one row per fixture case with a verdict column; the F4 collapsed record; and the assertions, one line each — that last one is what to read after an edit. Besides the row-by-row comparison it covers the counts and the volume case:
| Assertion | What it pins down |
|---|---|
| S14 | Four replacement rows enter the deduplication and four keys leave it — the conflict is still there for the load. Under a by list of the key alone the same rows would leave three keys. |
| S17 | Three replacement rows enter and two keys leave, under either by list: identical rows are still one event. |
| F4 | The collapsed record for a three-kit visit: ECREFID lists all three numbers, while the kit, lot and batch columns are the last one's. |
| S15 | 8,000 visit rows were walked, all of them on the terminal kit, none of them over the probe budget. |
Probes are asserted for every row, which is what distinguishes the two ways a walk can end: S07 must show exactly 100 probes and a status of UNRESOLVED, so a change that made the loop stop early would fail the check instead of looking like a fix.
7.4 How to run it
The file: kit_chain_check.sas, submitted as it stands.
sas kit_chain_check.sas # batch: the log carries the report and the exit code
# interactive: open the file and submit it
A passing run ends the log with one line:
kit chain check: 11 checks, 11 pass, 0 fail
11 is the number of rows the assertion table prints with the fixture as it stands, and the pass count has to equal it: one for the per-case comparison, three for each of the two duplicate cases, two for F4 and two for the volume case. A run that reports a smaller number has lost an assertion, not gained a pass.
Two things about the run are deliberate and are worth knowing before the log is read:
- The last block is expected to fail. S14 does not go through the loop with the others, because loading its conflict is supposed to stop the step. That block is the last thing in the file, it announces itself in the log, and it exists so the refusal is observed rather than cited. Set
demo_conflict = Nto skip it — which is also what to do where a scheduler treats anERRORin the log as a failed job. - The volume case dominates the runtime. 24,000 replacement rows and 8,000 walked rows are nothing for SAS, but they are the slowest part of the check. Lower
vol_subjectswhen volume is not the point being checked.
After any edit to the program, two commands keep this note honest:
python reference-model/code_fidelity.py # the listings, the fixture and the matrix, against the program
python reference-model/ec_step_model.py # the same fixture through a dependency-free emulation
7.5 What it settles, and what it does not
The check is code, not testimony: anything it asserts is settled by running it. Three behaviours are assumptions until then, and they are exactly what the run answers:
- That a load declared
duplicate: "error"ends the step with an error and produces no output data set, rather than warning and emitting a partial table. S14 rests on this. - That a zero-row table can be loaded through the
dataset:tag, which is how S10 reaches its dangling pointer. - That the
whiletest ofdo i = 1 to 100 while (…)is evaluated before the first iteration as well as before the rest, which is why S01 and S09 — rows whose pointer is already missing — must show zero probes.
What it does not settle is whether the fixture covers the shapes the real IRT extract can take, which no program of this size can answer. The cases were chosen from the ways a replacement chain breaks — an absent kit, a cycle, a conflict, a duplicate, a volume case — and a new shape of bad input means a new fixture line, not a re-reading of this note.
The emulation in reference-model/ec_step_model.py is not a second thing to maintain: it reads the fixture, the expected table and the volume parameters straight out of kit_chain_check.sas, so it cannot drift from it. It stays in the repository for the two things this environment cannot otherwise do: it runs without SAS, and it computes both deduplication counts and the answer each of them produces, which is the measurement behind §5.4.