cdisc-trace-agent 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Giri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: cdisc-trace-agent
3
+ Version: 1.0.0
4
+ Summary: Variable-level traceability checker for CDISC SDTM/ADaM submissions
5
+ Author: Giri
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/girishkankipati2-pixel/trace-agent
8
+ Keywords: cdisc,sdtm,adam,define-xml,clinical,traceability,fda,pharma
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Healthcare Industry
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: lxml>=5.0
19
+ Requires-Dist: pyreadstat>=1.2
20
+ Requires-Dist: pandas>=2.0
21
+ Dynamic: license-file
22
+
23
+ # trace-agent — clinical data traceability checker
24
+
25
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
26
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/)
27
+ [![PyPI](https://img.shields.io/badge/pypi-cdisc--trace--agent-orange.svg)](https://pypi.org/project/cdisc-trace-agent/)
28
+
29
+ **Free, open-source tool that finds traceability gaps in CDISC submissions before the FDA does.**
30
+
31
+ Given a study's CDISC `define.xml` (2.0/2.1) and its SAS transport (XPT)
32
+ datasets, `trace-agent` builds a **variable-level lineage graph**
33
+ (ADaM ← SDTM ← aCRF/protocol) and flags **traceability gaps** — the #1
34
+ source of findings in regulatory review.
35
+
36
+ 🚀 **[Try the live demo in your browser](https://muse.ai/s/trace-agent-xcxz5y1xossxxxao)** — no install needed.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install cdisc-trace-agent
42
+ ```
43
+
44
+ Or from source:
45
+
46
+ ```bash
47
+ git clone https://github.com/girishkankipati2-pixel/trace-agent.git
48
+ cd trace-agent
49
+ pip install -e .
50
+ ```
51
+
52
+ ## Run
53
+
54
+ ```bash
55
+ trace-agent --study <study-folder> --out report.html
56
+ ```
57
+
58
+ The study folder must contain `define.xml` at its root and `*.xpt` files
59
+ at the root or in an `xpt/` subdirectory. A text summary is printed and a
60
+ self-contained HTML report (inline SVG + JS, no external dependencies) is
61
+ written to `--out`. The report has summary counts, a gap table, and a
62
+ clickable lineage graph (click a node to highlight its lineage).
63
+
64
+ ## Demo
65
+
66
+ ```bash
67
+ python make_demo.py # builds demo_study/
68
+ python trace_agent.py --study demo_study --out demo_report.html
69
+ ```
70
+
71
+ `demo_study/` is a tiny synthetic study (SDTM DM + AE, ADaM ADSL) with five
72
+ deliberately planted gaps — see `demo_study/planted_gaps.txt`. The demo run
73
+ should report all of them.
74
+
75
+ ## Gaps detected
76
+
77
+ | Code | Meaning |
78
+ |---|---|
79
+ | `XPT_ONLY` | Variable present in XPT but missing from define.xml |
80
+ | `DEFINE_ONLY` | Variable defined in define.xml but missing from XPT |
81
+ | `DERIVED_NO_METHOD` | ADaM variable with Origin=`Derived` but no MethodDef/description |
82
+ | `DANGLING_REF` | Derivation references a variable defined nowhere |
83
+ | `NO_SDTM_PATH` | ADaM variable with no predecessor chain reaching SDTM |
84
+
85
+ ## How lineage is resolved
86
+
87
+ For each variable, predecessors are resolved from, in order:
88
+
89
+ 1. The linked `def:MethodDef` (via `ItemRef/@MethodOID`, per the
90
+ Define-XML 2.0 spec) — description and formal-expression text are scanned
91
+ for `DOMAIN.VAR` references.
92
+ 2. Inline `def:Origin` description text (same reference scanning).
93
+ 3. `def:Origin Type="Predecessor"` — links to the same-named variable in
94
+ SDTM datasets.
95
+
96
+ Explicit dotted references (`DM.AGE`) are authoritative; bare tokens only
97
+ count when they uniquely match one known variable name.
98
+
99
+ ## Limitations (v1)
100
+
101
+ - Annotated CRF / protocol documents are not parsed; CRF origins are
102
+ recorded but not linked to pages.
103
+ - `def:DocumentRef`, value-level metadata (`ValueListDef`), and ARM
104
+ (`arm:AnalysisResult`) are not yet followed.
105
+ - Dataset role (SDTM vs ADaM) uses `def:Class`, falling back to an `AD*`
106
+ name heuristic.
107
+ - Reference extraction from derivation prose is heuristic text matching,
108
+ not SAS parsing.
@@ -0,0 +1,86 @@
1
+ # trace-agent — clinical data traceability checker
2
+
3
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
4
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/)
5
+ [![PyPI](https://img.shields.io/badge/pypi-cdisc--trace--agent-orange.svg)](https://pypi.org/project/cdisc-trace-agent/)
6
+
7
+ **Free, open-source tool that finds traceability gaps in CDISC submissions before the FDA does.**
8
+
9
+ Given a study's CDISC `define.xml` (2.0/2.1) and its SAS transport (XPT)
10
+ datasets, `trace-agent` builds a **variable-level lineage graph**
11
+ (ADaM ← SDTM ← aCRF/protocol) and flags **traceability gaps** — the #1
12
+ source of findings in regulatory review.
13
+
14
+ 🚀 **[Try the live demo in your browser](https://muse.ai/s/trace-agent-xcxz5y1xossxxxao)** — no install needed.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install cdisc-trace-agent
20
+ ```
21
+
22
+ Or from source:
23
+
24
+ ```bash
25
+ git clone https://github.com/girishkankipati2-pixel/trace-agent.git
26
+ cd trace-agent
27
+ pip install -e .
28
+ ```
29
+
30
+ ## Run
31
+
32
+ ```bash
33
+ trace-agent --study <study-folder> --out report.html
34
+ ```
35
+
36
+ The study folder must contain `define.xml` at its root and `*.xpt` files
37
+ at the root or in an `xpt/` subdirectory. A text summary is printed and a
38
+ self-contained HTML report (inline SVG + JS, no external dependencies) is
39
+ written to `--out`. The report has summary counts, a gap table, and a
40
+ clickable lineage graph (click a node to highlight its lineage).
41
+
42
+ ## Demo
43
+
44
+ ```bash
45
+ python make_demo.py # builds demo_study/
46
+ python trace_agent.py --study demo_study --out demo_report.html
47
+ ```
48
+
49
+ `demo_study/` is a tiny synthetic study (SDTM DM + AE, ADaM ADSL) with five
50
+ deliberately planted gaps — see `demo_study/planted_gaps.txt`. The demo run
51
+ should report all of them.
52
+
53
+ ## Gaps detected
54
+
55
+ | Code | Meaning |
56
+ |---|---|
57
+ | `XPT_ONLY` | Variable present in XPT but missing from define.xml |
58
+ | `DEFINE_ONLY` | Variable defined in define.xml but missing from XPT |
59
+ | `DERIVED_NO_METHOD` | ADaM variable with Origin=`Derived` but no MethodDef/description |
60
+ | `DANGLING_REF` | Derivation references a variable defined nowhere |
61
+ | `NO_SDTM_PATH` | ADaM variable with no predecessor chain reaching SDTM |
62
+
63
+ ## How lineage is resolved
64
+
65
+ For each variable, predecessors are resolved from, in order:
66
+
67
+ 1. The linked `def:MethodDef` (via `ItemRef/@MethodOID`, per the
68
+ Define-XML 2.0 spec) — description and formal-expression text are scanned
69
+ for `DOMAIN.VAR` references.
70
+ 2. Inline `def:Origin` description text (same reference scanning).
71
+ 3. `def:Origin Type="Predecessor"` — links to the same-named variable in
72
+ SDTM datasets.
73
+
74
+ Explicit dotted references (`DM.AGE`) are authoritative; bare tokens only
75
+ count when they uniquely match one known variable name.
76
+
77
+ ## Limitations (v1)
78
+
79
+ - Annotated CRF / protocol documents are not parsed; CRF origins are
80
+ recorded but not linked to pages.
81
+ - `def:DocumentRef`, value-level metadata (`ValueListDef`), and ARM
82
+ (`arm:AnalysisResult`) are not yet followed.
83
+ - Dataset role (SDTM vs ADaM) uses `def:Class`, falling back to an `AD*`
84
+ name heuristic.
85
+ - Reference extraction from derivation prose is heuristic text matching,
86
+ not SAS parsing.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: cdisc-trace-agent
3
+ Version: 1.0.0
4
+ Summary: Variable-level traceability checker for CDISC SDTM/ADaM submissions
5
+ Author: Giri
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/girishkankipati2-pixel/trace-agent
8
+ Keywords: cdisc,sdtm,adam,define-xml,clinical,traceability,fda,pharma
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Healthcare Industry
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: lxml>=5.0
19
+ Requires-Dist: pyreadstat>=1.2
20
+ Requires-Dist: pandas>=2.0
21
+ Dynamic: license-file
22
+
23
+ # trace-agent — clinical data traceability checker
24
+
25
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
26
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/)
27
+ [![PyPI](https://img.shields.io/badge/pypi-cdisc--trace--agent-orange.svg)](https://pypi.org/project/cdisc-trace-agent/)
28
+
29
+ **Free, open-source tool that finds traceability gaps in CDISC submissions before the FDA does.**
30
+
31
+ Given a study's CDISC `define.xml` (2.0/2.1) and its SAS transport (XPT)
32
+ datasets, `trace-agent` builds a **variable-level lineage graph**
33
+ (ADaM ← SDTM ← aCRF/protocol) and flags **traceability gaps** — the #1
34
+ source of findings in regulatory review.
35
+
36
+ 🚀 **[Try the live demo in your browser](https://muse.ai/s/trace-agent-xcxz5y1xossxxxao)** — no install needed.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install cdisc-trace-agent
42
+ ```
43
+
44
+ Or from source:
45
+
46
+ ```bash
47
+ git clone https://github.com/girishkankipati2-pixel/trace-agent.git
48
+ cd trace-agent
49
+ pip install -e .
50
+ ```
51
+
52
+ ## Run
53
+
54
+ ```bash
55
+ trace-agent --study <study-folder> --out report.html
56
+ ```
57
+
58
+ The study folder must contain `define.xml` at its root and `*.xpt` files
59
+ at the root or in an `xpt/` subdirectory. A text summary is printed and a
60
+ self-contained HTML report (inline SVG + JS, no external dependencies) is
61
+ written to `--out`. The report has summary counts, a gap table, and a
62
+ clickable lineage graph (click a node to highlight its lineage).
63
+
64
+ ## Demo
65
+
66
+ ```bash
67
+ python make_demo.py # builds demo_study/
68
+ python trace_agent.py --study demo_study --out demo_report.html
69
+ ```
70
+
71
+ `demo_study/` is a tiny synthetic study (SDTM DM + AE, ADaM ADSL) with five
72
+ deliberately planted gaps — see `demo_study/planted_gaps.txt`. The demo run
73
+ should report all of them.
74
+
75
+ ## Gaps detected
76
+
77
+ | Code | Meaning |
78
+ |---|---|
79
+ | `XPT_ONLY` | Variable present in XPT but missing from define.xml |
80
+ | `DEFINE_ONLY` | Variable defined in define.xml but missing from XPT |
81
+ | `DERIVED_NO_METHOD` | ADaM variable with Origin=`Derived` but no MethodDef/description |
82
+ | `DANGLING_REF` | Derivation references a variable defined nowhere |
83
+ | `NO_SDTM_PATH` | ADaM variable with no predecessor chain reaching SDTM |
84
+
85
+ ## How lineage is resolved
86
+
87
+ For each variable, predecessors are resolved from, in order:
88
+
89
+ 1. The linked `def:MethodDef` (via `ItemRef/@MethodOID`, per the
90
+ Define-XML 2.0 spec) — description and formal-expression text are scanned
91
+ for `DOMAIN.VAR` references.
92
+ 2. Inline `def:Origin` description text (same reference scanning).
93
+ 3. `def:Origin Type="Predecessor"` — links to the same-named variable in
94
+ SDTM datasets.
95
+
96
+ Explicit dotted references (`DM.AGE`) are authoritative; bare tokens only
97
+ count when they uniquely match one known variable name.
98
+
99
+ ## Limitations (v1)
100
+
101
+ - Annotated CRF / protocol documents are not parsed; CRF origins are
102
+ recorded but not linked to pages.
103
+ - `def:DocumentRef`, value-level metadata (`ValueListDef`), and ARM
104
+ (`arm:AnalysisResult`) are not yet followed.
105
+ - Dataset role (SDTM vs ADaM) uses `def:Class`, falling back to an `AD*`
106
+ name heuristic.
107
+ - Reference extraction from derivation prose is heuristic text matching,
108
+ not SAS parsing.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ trace_agent.py
5
+ cdisc_trace_agent.egg-info/PKG-INFO
6
+ cdisc_trace_agent.egg-info/SOURCES.txt
7
+ cdisc_trace_agent.egg-info/dependency_links.txt
8
+ cdisc_trace_agent.egg-info/entry_points.txt
9
+ cdisc_trace_agent.egg-info/requires.txt
10
+ cdisc_trace_agent.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ trace-agent = trace_agent:main
@@ -0,0 +1,3 @@
1
+ lxml>=5.0
2
+ pyreadstat>=1.2
3
+ pandas>=2.0
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cdisc-trace-agent"
7
+ version = "1.0.0"
8
+ description = "Variable-level traceability checker for CDISC SDTM/ADaM submissions"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Giri" }]
13
+ keywords = ["cdisc", "sdtm", "adam", "define-xml", "clinical", "traceability", "fda", "pharma"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Healthcare Industry",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
21
+ ]
22
+ dependencies = [
23
+ "lxml>=5.0",
24
+ "pyreadstat>=1.2",
25
+ "pandas>=2.0",
26
+ ]
27
+
28
+ [project.scripts]
29
+ trace-agent = "trace_agent:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/girishkankipati2-pixel/trace-agent"
33
+
34
+ [tool.setuptools]
35
+ py-modules = ["trace_agent"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,730 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ trace.py -- Clinical data traceability agent (v1 prototype).
4
+
5
+ Given a study folder containing a CDISC define.xml (2.0/2.1) and SAS
6
+ transport (XPT) datasets, this tool:
7
+
8
+ 1. Parses define.xml for datasets (ItemGroupDef), variables (ItemDef,
9
+ incl. def:Origin), derivations (def:MethodDef) and the ItemRef ->
10
+ MethodDef linkage (ItemRef/@MethodOID, per the Define-XML 2.0 spec).
11
+ 2. Reads the XPT datasets and compares them against define.xml.
12
+ 3. Builds a variable-level lineage graph (ADaM <- SDTM <- aCRF/protocol).
13
+ 4. Flags traceability gaps.
14
+ 5. Writes a single self-contained HTML report (inline SVG + JS).
15
+
16
+ Usage:
17
+ python trace.py --study <folder> --out report.html
18
+
19
+ Study folder layout expected:
20
+ <folder>/define.xml (CDISC Define-XML 2.0 or 2.1)
21
+ <folder>/*.xpt (SAS transport files, e.g. dm.xpt)
22
+ <folder>/xpt/*.xpt (also searched)
23
+ """
24
+
25
+ import argparse
26
+ import html as html_lib
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ from collections import deque
32
+
33
+ try:
34
+ from lxml import etree
35
+ except ImportError:
36
+ sys.exit("ERROR: lxml is required. Run: pip install -r requirements.txt")
37
+
38
+ try:
39
+ import pyreadstat
40
+ except ImportError:
41
+ sys.exit("ERROR: pyreadstat is required. Run: pip install -r requirements.txt")
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # XML helpers (namespace-agnostic so both define 2.0 and 2.1 parse the same)
46
+ # ---------------------------------------------------------------------------
47
+
48
+ DEF_NS_URIS = (
49
+ "http://www.cdisc.org/ns/def/v2.0",
50
+ "http://www.cdisc.org/ns/def/v2.1",
51
+ )
52
+ XLINK_HREF = "{http://www.w3.org/1999/xlink}href"
53
+
54
+
55
+ def local(tag):
56
+ """Strip any namespace, returning the local element name."""
57
+ if not isinstance(tag, str): # comments/PIs have non-string tags
58
+ return ""
59
+ return tag.rsplit("}", 1)[-1] if "}" in tag else tag
60
+
61
+
62
+ def child(el, name):
63
+ for c in el:
64
+ if local(c.tag) == name:
65
+ return c
66
+ return None
67
+
68
+
69
+ def children(el, name):
70
+ return [c for c in el if local(c.tag) == name]
71
+
72
+
73
+ def def_attr(el, name):
74
+ """Read a define-extension attribute (def:Name) regardless of 2.0/2.1."""
75
+ for uri in DEF_NS_URIS:
76
+ v = el.get("{%s}%s" % (uri, name))
77
+ if v is not None:
78
+ return v
79
+ return None
80
+
81
+
82
+ def desc_text(el):
83
+ """Text of <Description><TranslatedText>...</TranslatedText></Description>."""
84
+ if el is None:
85
+ return ""
86
+ d = child(el, "Description")
87
+ if d is None:
88
+ return ""
89
+ t = child(d, "TranslatedText")
90
+ target = t if t is not None else d
91
+ return "".join(target.itertext()).strip()
92
+
93
+
94
+ def formal_expression_text(method_el):
95
+ parts = []
96
+ for fe in method_el.iter():
97
+ if local(fe.tag) == "FormalExpression":
98
+ ctx = fe.get("Context") or ""
99
+ body = "".join(fe.itertext()).strip()
100
+ parts.append(("[%s] %s" % (ctx, body)) if ctx else body)
101
+ return " ".join(parts).strip()
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # define.xml parsing
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def parse_define(path):
109
+ """Parse define.xml -> (datasets, variables, methods).
110
+
111
+ datasets: {DSNAME: {name, label, domain, role, xpt, var_order:[VARNAME]}}
112
+ variables: {"DS.VAR": {dataset, name, label, dtype, length, origin_type,
113
+ origin_desc, method_oid}}
114
+ methods: {OID: {oid, name, type, description, formal}}
115
+ role is 'ADAM' or 'SDTM' (def:Class wins; otherwise AD* name heuristic).
116
+ """
117
+ tree = etree.parse(path)
118
+ root = tree.getroot()
119
+
120
+ mdv = None
121
+ for el in root.iter():
122
+ if local(el.tag) == "MetaDataVersion":
123
+ mdv = el
124
+ break
125
+ if mdv is None:
126
+ raise ValueError("No MetaDataVersion element found in %s" % path)
127
+
128
+ # def:leaf ID -> href (maps datasets to their XPT files)
129
+ leaves = {}
130
+ for el in mdv.iter():
131
+ if local(el.tag) == "leaf":
132
+ lid = el.get("ID")
133
+ href = el.get(XLINK_HREF)
134
+ if lid and href:
135
+ leaves[lid] = href
136
+
137
+ # def:MethodDef elements: the documented derivation algorithms.
138
+ methods = {}
139
+ for el in mdv.iter():
140
+ if local(el.tag) == "MethodDef":
141
+ oid = el.get("OID")
142
+ if not oid:
143
+ continue
144
+ methods[oid] = {
145
+ "oid": oid,
146
+ "name": el.get("Name") or "",
147
+ "type": el.get("Type") or "",
148
+ "description": desc_text(el),
149
+ "formal": formal_expression_text(el),
150
+ }
151
+
152
+ # ItemDef elements: variable-level metadata incl. def:Origin.
153
+ itemdefs = {}
154
+ for el in mdv.iter():
155
+ if local(el.tag) == "ItemDef":
156
+ oid = el.get("OID")
157
+ if not oid:
158
+ continue
159
+ origin_el = child(el, "Origin")
160
+ otype = ""
161
+ odesc = ""
162
+ if origin_el is not None:
163
+ otype = origin_el.get("Type") or ""
164
+ odesc = desc_text(origin_el)
165
+ itemdefs[oid] = {
166
+ "oid": oid,
167
+ "name": (el.get("Name") or "").upper(),
168
+ "label": desc_text(el),
169
+ "dtype": el.get("DataType") or "",
170
+ "length": el.get("Length") or "",
171
+ "origin_type": otype,
172
+ "origin_desc": odesc,
173
+ }
174
+
175
+ # ItemGroupDef elements: datasets and their ordered ItemRefs.
176
+ # Per the Define-XML 2.0 spec, an ItemRef carries MethodOID to link a
177
+ # variable to its derivation method (def:MethodDef).
178
+ datasets = {}
179
+ variables = {}
180
+ for ig in mdv:
181
+ if local(ig.tag) != "ItemGroupDef":
182
+ continue
183
+ name = (ig.get("Name") or "").upper()
184
+ if not name:
185
+ continue
186
+ cls = (def_attr(ig, "Class") or "").upper()
187
+ if "ADAM" in cls:
188
+ role = "ADAM"
189
+ elif name.startswith("AD"):
190
+ role = "ADAM" # ADSL, ADAE, ... (fallback heuristic)
191
+ else:
192
+ role = "SDTM"
193
+ leaf_id = def_attr(ig, "ArchiveLocationID")
194
+ href = leaves.get(leaf_id, "") if leaf_id else ""
195
+ xpt = os.path.basename(href) if href else (name.lower() + ".xpt")
196
+
197
+ var_order = []
198
+ for ir in children(ig, "ItemRef"):
199
+ item_oid = ir.get("ItemOID")
200
+ idef = itemdefs.get(item_oid)
201
+ if idef is None or not idef["name"]:
202
+ continue
203
+ # MethodOID is a plain attribute on ItemRef in the spec example;
204
+ # also accept a def-namespaced variant for tolerant parsing.
205
+ method_oid = ir.get("MethodOID") or def_attr(ir, "MethodOID") or ""
206
+ vname = idef["name"]
207
+ var_order.append(vname)
208
+ variables["%s.%s" % (name, vname)] = {
209
+ "dataset": name,
210
+ "name": vname,
211
+ "label": idef["label"],
212
+ "dtype": idef["dtype"],
213
+ "length": idef["length"],
214
+ "origin_type": idef["origin_type"],
215
+ "origin_desc": idef["origin_desc"],
216
+ "method_oid": method_oid,
217
+ }
218
+ datasets[name] = {
219
+ "name": name,
220
+ "label": desc_text(ig),
221
+ "domain": ig.get("Domain") or name,
222
+ "role": role,
223
+ "xpt": xpt,
224
+ "var_order": var_order,
225
+ }
226
+
227
+ return datasets, variables, methods
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # XPT reading
231
+ # ---------------------------------------------------------------------------
232
+
233
+ def find_xpt_files(study_dir):
234
+ """Map UPPER(file stem) -> path for every *.xpt under study_dir[/xpt]."""
235
+ found = {}
236
+ for base in (study_dir, os.path.join(study_dir, "xpt")):
237
+ if not os.path.isdir(base):
238
+ continue
239
+ for f in sorted(os.listdir(base)):
240
+ if f.lower().endswith(".xpt"):
241
+ found[os.path.splitext(f)[0].upper()] = os.path.join(base, f)
242
+ return found
243
+
244
+
245
+ def read_xpt(path):
246
+ """Return (upper-cased variable names, row count) for an XPT file."""
247
+ df, meta = pyreadstat.read_xport(path)
248
+ names = [str(c).upper() for c in meta.column_names]
249
+ return names, len(df)
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Lineage: resolve each variable's predecessor variables
254
+ # ---------------------------------------------------------------------------
255
+
256
+ DOTTED_REF = re.compile(r"\b([A-Z][A-Z0-9]{0,7})\.([A-Z][A-Z0-9_]{0,7})\b")
257
+ BARE_TOKEN = re.compile(r"\b([A-Z][A-Z0-9_]{2,8})\b")
258
+ # Words that look like variable names but are just derivation prose.
259
+ STOPWORDS = {
260
+ "AND", "OR", "NOT", "IF", "THEN", "ELSE", "WHEN", "SET", "THE", "FOR",
261
+ "FROM", "WHERE", "WITH", "NULL", "INTO", "AS", "IS", "TO", "OF", "ON",
262
+ "BY", "AT", "BE", "AN", "DERIVED", "MAPPED", "VALUE", "VALUES",
263
+ }
264
+
265
+
266
+ def extract_refs(text, known_vars):
267
+ """Find predecessor variable references in free derivation text.
268
+
269
+ Returns (refs, dangling):
270
+ refs - {"DS.VAR"} tokens that exist in the study metadata
271
+ dangling - {"DS.VAR"} tokens that look explicit but match nothing
272
+ Explicit dotted tokens (DM.AGE) are authoritative; bare tokens only
273
+ count when they uniquely match one known variable name.
274
+ """
275
+ refs, dangling = set(), set()
276
+ if not text:
277
+ return refs, dangling
278
+ t = text.upper()
279
+ for m in DOTTED_REF.finditer(t):
280
+ key = "%s.%s" % (m.group(1), m.group(2))
281
+ if key in known_vars:
282
+ refs.add(key)
283
+ else:
284
+ dangling.add(key)
285
+ by_name = {}
286
+ for k in known_vars:
287
+ by_name.setdefault(k.split(".", 1)[1], []).append(k)
288
+ for m in BARE_TOKEN.finditer(t):
289
+ tok = m.group(1)
290
+ if tok in STOPWORDS:
291
+ continue
292
+ hits = by_name.get(tok, [])
293
+ if len(hits) == 1:
294
+ refs.add(hits[0])
295
+ return refs, dangling
296
+
297
+
298
+ def resolve_predecessors(datasets, variables, methods):
299
+ """For every variable, resolve predecessor variable keys.
300
+
301
+ Sources, in order: linked def:MethodDef text (via ItemRef/@MethodOID),
302
+ inline def:Origin description text, and Origin Type='Predecessor'
303
+ (same-named variable in an SDTM dataset).
304
+ Returns {varkey: {'refs': set, 'dangling': set, 'via': str}}.
305
+ """
306
+ known_vars = set(variables)
307
+ sdtm_ds = [n for n, d in datasets.items() if d["role"] == "SDTM"]
308
+ sdtm_names = {}
309
+ for ds in sdtm_ds:
310
+ for v in datasets[ds]["var_order"]:
311
+ sdtm_names.setdefault(v, []).append(ds)
312
+
313
+ pred = {}
314
+ for vkey, var in variables.items():
315
+ refs, dangling, via = set(), set(), []
316
+ if var["method_oid"]:
317
+ m = methods.get(var["method_oid"])
318
+ if m:
319
+ r, d = extract_refs(
320
+ (m["description"] + " " + m["formal"]).strip(), known_vars)
321
+ refs |= r
322
+ dangling |= d
323
+ via.append("MethodDef %s" % m["oid"])
324
+ else:
325
+ via.append("MethodDef %s (missing!)" % var["method_oid"])
326
+ if var["origin_desc"]:
327
+ r, d = extract_refs(var["origin_desc"], known_vars)
328
+ refs |= r
329
+ dangling |= d
330
+ if r or d:
331
+ via.append("Origin description")
332
+ if var["origin_type"] == "Predecessor" and not refs:
333
+ # Fallback when the derivation text names no explicit source:
334
+ # link to the same-named variable in SDTM datasets.
335
+ for ds in sdtm_names.get(var["name"], []):
336
+ refs.add("%s.%s" % (ds, var["name"]))
337
+ if sdtm_names.get(var["name"]):
338
+ via.append("Origin=Predecessor")
339
+ refs.discard(vkey) # a variable is never its own predecessor
340
+ pred[vkey] = {"refs": refs, "dangling": dangling,
341
+ "via": "; ".join(via) if via else "none"}
342
+ return pred
343
+
344
+
345
+ def reaches_sdtm(vkey, pred, variables, datasets):
346
+ """True if following predecessor edges reaches any SDTM variable."""
347
+ seen = {vkey}
348
+ queue = deque([vkey])
349
+ while queue:
350
+ cur = queue.popleft()
351
+ for nxt in pred.get(cur, {}).get("refs", ()):
352
+ if nxt in seen:
353
+ continue
354
+ seen.add(nxt)
355
+ ds = variables[nxt]["dataset"]
356
+ if datasets[ds]["role"] == "SDTM":
357
+ return True
358
+ queue.append(nxt)
359
+ return False
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Gap detection
364
+ # ---------------------------------------------------------------------------
365
+ # (a) variable in XPT but missing from define.xml
366
+ # (b) variable in define.xml but missing from XPT
367
+ # (c) ADaM variable with Origin='Derived' but no documented derivation
368
+ # (d) derivation references a variable that exists nowhere
369
+ # (e) ADaM variable with no lineage path back to any SDTM variable
370
+
371
+ GAP_LABELS = {
372
+ "XPT_ONLY": "In XPT, missing from define.xml",
373
+ "DEFINE_ONLY": "In define.xml, missing from XPT",
374
+ "DERIVED_NO_METHOD": "Origin=Derived but no derivation documented",
375
+ "DANGLING_REF": "Derivation references undefined variable",
376
+ "NO_SDTM_PATH": "No lineage path back to any SDTM variable",
377
+ }
378
+
379
+
380
+ def method_documented(var, methods):
381
+ """A derivation counts as documented with a linked MethodDef that has
382
+ text, or with an inline Origin description."""
383
+ if var["method_oid"]:
384
+ m = methods.get(var["method_oid"])
385
+ if m and (m["description"] or m["formal"]):
386
+ return True
387
+ return bool(var["origin_desc"].strip())
388
+
389
+
390
+ def detect_gaps(datasets, variables, methods, pred, xpt_vars):
391
+ gaps = []
392
+
393
+ def add(code, dataset, variable, detail):
394
+ gaps.append({"code": code, "label": GAP_LABELS[code],
395
+ "dataset": dataset, "variable": variable,
396
+ "detail": detail})
397
+
398
+ # (a) + (b): define.xml vs XPT agreement, per dataset
399
+ for dsname, ds in datasets.items():
400
+ stem = os.path.splitext(ds["xpt"])[0].upper()
401
+ actual = xpt_vars.get(stem)
402
+ defined = set(ds["var_order"])
403
+ if actual is None:
404
+ add("DEFINE_ONLY", dsname, "(dataset)",
405
+ "define.xml dataset has no matching XPT file (%s)" % ds["xpt"])
406
+ continue
407
+ actual_set = set(actual)
408
+ for v in sorted(actual_set - defined):
409
+ add("XPT_ONLY", dsname, v,
410
+ "present in %s but not defined in define.xml" % ds["xpt"])
411
+ for v in sorted(defined - actual_set):
412
+ add("DEFINE_ONLY", dsname, v,
413
+ "defined in define.xml but missing from %s" % ds["xpt"])
414
+
415
+ # (c), (d), (e): derivation-level checks
416
+ for vkey, var in variables.items():
417
+ ds = var["dataset"]
418
+ if datasets[ds]["role"] != "ADAM":
419
+ continue
420
+ if var["origin_type"] == "Derived" and not method_documented(var, methods):
421
+ add("DERIVED_NO_METHOD", ds, var["name"],
422
+ "Origin is 'Derived' but no MethodDef/description documents how")
423
+ for d in sorted(pred[vkey]["dangling"]):
424
+ add("DANGLING_REF", ds, var["name"],
425
+ "derivation references %s, which is defined nowhere" % d)
426
+ if not reaches_sdtm(vkey, pred, variables, datasets):
427
+ add("NO_SDTM_PATH", ds, var["name"],
428
+ "no predecessor chain reaches any SDTM variable")
429
+
430
+ # stable ordering: by gap type, then dataset, then variable
431
+ order = list(GAP_LABELS)
432
+ gaps.sort(key=lambda g: (order.index(g["code"]), g["dataset"], g["variable"]))
433
+ return gaps
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # HTML report (single self-contained file: inline SVG + JS, no CDN)
437
+ # ---------------------------------------------------------------------------
438
+
439
+ CSS = """
440
+ body{font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;
441
+ margin:0;background:#f6f8fb;color:#1c2733}
442
+ header{background:#0f2a44;color:#fff;padding:22px 32px}
443
+ header h1{margin:0;font-size:22px} header p{margin:6px 0 0;color:#b9c8da}
444
+ main{padding:24px 32px;max-width:1400px}
445
+ .cards{display:flex;gap:14px;flex-wrap:wrap;margin-bottom:22px}
446
+ .card{background:#fff;border:1px solid #dfe6ef;border-radius:10px;
447
+ padding:14px 20px;min-width:150px}
448
+ .card .n{font-size:26px;font-weight:700}.card .l{font-size:12px;color:#5b6b7f}
449
+ .card.bad .n{color:#c0392b}
450
+ h2{font-size:17px;margin:26px 0 10px}
451
+ table{border-collapse:collapse;width:100%;background:#fff;font-size:13px}
452
+ th,td{border:1px solid #dfe6ef;padding:8px 10px;text-align:left;vertical-align:top}
453
+ th{background:#eaf0f7}
454
+ .badge{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11px;
455
+ font-weight:700;white-space:nowrap}
456
+ .XPT_ONLY{background:#fdebd0;color:#935116}
457
+ .DEFINE_ONLY{background:#f9ebea;color:#922b21}
458
+ .DERIVED_NO_METHOD{background:#f5cba7;color:#7e5109}
459
+ .DANGLING_REF{background:#fadbd8;color:#943126}
460
+ .NO_SDTM_PATH{background:#e8daef;color:#6c3483}
461
+ #graph{background:#fff;border:1px solid #dfe6ef;border-radius:10px;
462
+ overflow:auto;margin-top:6px}
463
+ .node{cursor:pointer}
464
+ .node rect{fill:#fff;stroke:#7f9bb3;stroke-width:1.5;rx:6}
465
+ .node text{font-size:12px;fill:#1c2733}
466
+ .node.gapvar rect{stroke:#c0392b;stroke-width:2.5}
467
+ .node.xptonly rect{stroke-dasharray:6 4;fill:#fdf6ec}
468
+ .node.dim{opacity:.18}.edge.dim{opacity:.08}
469
+ .node.hl rect{stroke:#0f2a44;stroke-width:3}
470
+ .edge{stroke:#7f9bb3;stroke-width:1.5;fill:none;opacity:.75}
471
+ .edge.hl{stroke:#0f2a44;stroke-width:3;opacity:1}
472
+ .dslabel{font-size:14px;font-weight:700;fill:#0f2a44}
473
+ #detail{background:#fff;border:1px solid #dfe6ef;border-radius:10px;
474
+ padding:14px 18px;margin-top:14px;font-size:13px;min-height:90px}
475
+ #detail h3{margin:0 0 8px;font-size:15px}
476
+ .kv{color:#5b6b7f}.legend{font-size:12px;color:#5b6b7f;margin-top:8px}
477
+ footer{padding:18px 32px;color:#8a97a8;font-size:12px}
478
+ """
479
+
480
+ JS = """
481
+ function clearHl(){
482
+ document.querySelectorAll('.node.hl,.edge.hl,.node.dim,.edge.dim')
483
+ .forEach(function(e){e.classList.remove('hl','dim')});
484
+ document.getElementById('detail').innerHTML='<span class="kv">Click a variable node to inspect its lineage.</span>';
485
+ }
486
+ function showDetail(id){
487
+ var v=VAR_INFO[id]; if(!v) return;
488
+ var h='<h3>'+v.key+'</h3>';
489
+ h+='<div><span class="kv">Label:</span> '+v.label+'</div>';
490
+ h+='<div><span class="kv">Origin:</span> '+v.origin+'</div>';
491
+ h+='<div><span class="kv">Method:</span> '+v.method+'</div>';
492
+ h+='<div><span class="kv">Predecessors:</span> '+(v.preds.length?v.preds.join(', '):'<i>none</i>')+'</div>';
493
+ h+='<div><span class="kv">Resolved via:</span> '+v.via+'</div>';
494
+ if(v.gaps.length){h+='<div style="margin-top:6px"><span class="kv">Gaps:</span><ul style="margin:4px 0">';
495
+ v.gaps.forEach(function(g){h+='<li><b>'+g.code+'</b> - '+g.detail+'</li>'});h+='</ul></div>'}
496
+ document.getElementById('detail').innerHTML=h;
497
+ }
498
+ document.querySelectorAll('.node').forEach(function(n){
499
+ n.addEventListener('click',function(ev){
500
+ ev.stopPropagation();
501
+ var id=n.id, wasHl=n.classList.contains('hl');
502
+ clearHl(); if(wasHl) return;
503
+ var keep={}; keep[id]=1;
504
+ document.querySelectorAll('.edge').forEach(function(e){
505
+ if(e.dataset.from===id||e.dataset.to===id){
506
+ e.classList.add('hl');keep[e.dataset.from]=1;keep[e.dataset.to]=1;
507
+ } else e.classList.add('dim');
508
+ });
509
+ document.querySelectorAll('.node').forEach(function(m){
510
+ if(keep[m.id]) m.classList.add('hl'); else m.classList.add('dim');
511
+ });
512
+ showDetail(id);
513
+ });
514
+ });
515
+ document.getElementById('svgroot').addEventListener('click',clearHl);
516
+ """
517
+
518
+
519
+ def node_id(key):
520
+ return "n-" + key.replace(".", "-")
521
+
522
+
523
+ def build_html(study_name, define_name, datasets, variables, methods, pred,
524
+ edges, gaps, xpt_vars, xpt_rows):
525
+ esc = html_lib.escape
526
+ n_vars = len(variables)
527
+ n_edges = len(edges)
528
+ n_gaps = len(gaps)
529
+ by_type = {}
530
+ for g in gaps:
531
+ by_type[g["code"]] = by_type.get(g["code"], 0) + 1
532
+
533
+ # ---- layout: one column per dataset (SDTM left, ADaM right) ----
534
+ sdtm = sorted([n for n, d in datasets.items() if d["role"] == "SDTM"])
535
+ adam = sorted([n for n, d in datasets.items() if d["role"] != "SDTM"])
536
+ cols = sdtm + adam
537
+ NW, NH, VGAP, COLW, TOP, LEFT = 230, 30, 12, 300, 100, 40
538
+ pos = {} # varkey -> (x, y)
539
+ for ci, ds in enumerate(cols):
540
+ x = LEFT + ci * COLW
541
+ # define vars first (in define order), then XPT-only vars (dashed)
542
+ keys = ["%s.%s" % (ds, v) for v in datasets[ds]["var_order"]]
543
+ stem = os.path.splitext(datasets[ds]["xpt"])[0].upper()
544
+ defined = set(datasets[ds]["var_order"])
545
+ for v in (xpt_vars.get(stem) or []):
546
+ if v not in defined:
547
+ keys.append("%s.%s" % (ds, v))
548
+ for i, k in enumerate(keys):
549
+ pos[k] = (x, TOP + i * (NH + VGAP))
550
+ ncols = max(len(cols), 1)
551
+ maxrows = max((len([k for k in pos if k.startswith(c + ".")])
552
+ for c in cols), default=1)
553
+ W = LEFT * 2 + ncols * COLW
554
+ H = TOP + maxrows * (NH + VGAP) + 50
555
+
556
+ # ---- SVG ----
557
+ svg = ['<svg id="svgroot" width="%d" height="%d" '
558
+ 'xmlns="http://www.w3.org/2000/svg">' % (W, H)]
559
+ for ci, ds in enumerate(cols):
560
+ x = LEFT + ci * COLW
561
+ svg.append('<text class="dslabel" x="%d" y="40">%s</text>' % (x, esc(ds)))
562
+ svg.append('<text x="%d" y="60" font-size="11" fill="#5b6b7f">%s - %s</text>'
563
+ % (x, esc(datasets[ds]["role"]),
564
+ esc(datasets[ds]["xpt"])))
565
+ gap_vars = {g["dataset"] + "." + g["variable"] for g in gaps}
566
+ for k, (x, y) in pos.items():
567
+ nid = node_id(k)
568
+ cls = "node"
569
+ if k in gap_vars:
570
+ cls += " gapvar"
571
+ if k not in variables:
572
+ cls += " xptonly"
573
+ short = k if len(k) <= 26 else k[:25] + "…"
574
+ svg.append(
575
+ '<g class="%s" id="%s"><rect x="%d" y="%d" width="%d" height="%d" rx="6"/>'
576
+ '<text x="%d" y="%d">%s</text></g>'
577
+ % (cls, nid, x, y, NW, NH, x + 10, y + 20, esc(short)))
578
+ for (a, b) in edges:
579
+ if a not in pos or b not in pos:
580
+ continue
581
+ x1, y1 = pos[a][0] + NW, pos[a][1] + NH / 2
582
+ x2, y2 = pos[b][0], pos[b][1] + NH / 2
583
+ mx = (x1 + x2) / 2
584
+ svg.append(
585
+ '<path class="edge" data-from="%s" data-to="%s" '
586
+ 'd="M %d %d C %d %d, %d %d, %d %d"/>'
587
+ % (node_id(a), node_id(b), x1, y1, mx, y1, mx, y2, x2, y2))
588
+ svg.append("</svg>")
589
+ svg_html = "\n".join(svg)
590
+
591
+ # ---- gap table ----
592
+ rows = []
593
+ for i, g in enumerate(gaps, 1):
594
+ rows.append(
595
+ "<tr><td>%d</td><td><span class='badge %s'>%s</span></td>"
596
+ "<td>%s</td><td><b>%s</b></td><td>%s</td></tr>"
597
+ % (i, g["code"], esc(g["code"]), esc(g["dataset"]),
598
+ esc(g["variable"]), esc(g["detail"])))
599
+ gap_table = ("\n".join(rows) if rows
600
+ else "<tr><td colspan='5'>No gaps found.</td></tr>")
601
+
602
+ # ---- JS detail data ----
603
+ gaps_by_var = {}
604
+ for g in gaps:
605
+ gaps_by_var.setdefault(g["dataset"] + "." + g["variable"], []).append(
606
+ {"code": g["code"], "detail": g["detail"]})
607
+ var_info = {}
608
+ for k, v in variables.items():
609
+ m = methods.get(v["method_oid"]) if v["method_oid"] else None
610
+ var_info[node_id(k)] = {
611
+ "key": k,
612
+ "label": esc(v["label"] or "-"),
613
+ "origin": esc(v["origin_type"] or "-"),
614
+ "method": esc(("%s: %s" % (m["oid"], m["description"] or m["formal"] or "-"))
615
+ if m else ("(%s, undocumented)" % v["method_oid"]
616
+ if v["method_oid"] else "-")),
617
+ "preds": sorted(pred[k]["refs"]),
618
+ "via": esc(pred[k]["via"]),
619
+ "gaps": gaps_by_var.get(k, []),
620
+ }
621
+ for k in pos:
622
+ if k not in variables: # XPT-only nodes
623
+ ds, vn = k.split(".", 1)
624
+ var_info[node_id(k)] = {
625
+ "key": k, "label": "-", "origin": "not in define.xml",
626
+ "method": "-", "preds": [], "via": "none",
627
+ "gaps": gaps_by_var.get(k, []),
628
+ }
629
+
630
+ cards = [
631
+ ("Datasets", len(datasets), ""),
632
+ ("Variables (define.xml)", n_vars, ""),
633
+ ("XPT files read", len(xpt_vars), ""),
634
+ ("Lineage edges", n_edges, ""),
635
+ ("Traceability gaps", n_gaps, "bad" if n_gaps else ""),
636
+ ]
637
+ cards_html = "".join(
638
+ '<div class="card %s"><div class="n">%d</div><div class="l">%s</div></div>'
639
+ % (c, n, l) for l, n, c in cards)
640
+
641
+ type_rows = "".join(
642
+ "<tr><td><span class='badge %s'>%s</span></td><td>%s</td><td>%d</td></tr>"
643
+ % (code, esc(code), esc(GAP_LABELS[code]), by_type.get(code, 0))
644
+ for code in GAP_LABELS)
645
+
646
+ return ("<!DOCTYPE html><html><head><meta charset='utf-8'>"
647
+ "<title>Traceability report - %s</title><style>%s</style></head><body>"
648
+ "<header><h1>Traceability report: %s</h1>"
649
+ "<p>define.xml: %s &nbsp;|&nbsp; generated by trace.py</p></header><main>"
650
+ "<div class='cards'>%s</div>"
651
+ "<h2>Gaps by type</h2><table><tr><th>Type</th><th>Meaning</th><th>Count</th></tr>%s</table>"
652
+ "<h2>Gap details (%d)</h2>"
653
+ "<table><tr><th>#</th><th>Type</th><th>Dataset</th><th>Variable</th><th>Detail</th></tr>%s</table>"
654
+ "<h2>Lineage graph</h2>"
655
+ "<div class='legend'>SDTM datasets on the left, ADaM on the right. "
656
+ "Red border = variable has gaps. Dashed = in XPT but not in define.xml. "
657
+ "Click a node to highlight its lineage.</div>"
658
+ "<div id='graph'>%s</div><div id='detail'>"
659
+ "<span class='kv'>Click a variable node to inspect its lineage.</span></div>"
660
+ "</main><footer>trace.py v1 prototype - variable-level lineage from "
661
+ "define.xml + XPT</footer>"
662
+ "<script>var VAR_INFO = %s;</script><script>%s</script>"
663
+ "</body></html>"
664
+ % (esc(study_name), CSS, esc(study_name), esc(define_name),
665
+ cards_html, type_rows, n_gaps, gap_table, svg_html,
666
+ json.dumps(var_info), JS))
667
+
668
+
669
+ # ---------------------------------------------------------------------------
670
+ # CLI
671
+ # ---------------------------------------------------------------------------
672
+
673
+ def find_define(study_dir):
674
+ for f in sorted(os.listdir(study_dir)):
675
+ if f.lower() == "define.xml":
676
+ return os.path.join(study_dir, f)
677
+ raise FileNotFoundError("No define.xml found in %s" % study_dir)
678
+
679
+
680
+ def main(argv=None):
681
+ ap = argparse.ArgumentParser(
682
+ description="Build a variable-level lineage graph from define.xml + "
683
+ "XPT and flag traceability gaps.")
684
+ ap.add_argument("--study", required=True,
685
+ help="Study folder with define.xml and *.xpt files")
686
+ ap.add_argument("--out", required=True, help="Output HTML report path")
687
+ args = ap.parse_args(argv)
688
+
689
+ study = args.study
690
+ define_path = find_define(study)
691
+ datasets, variables, methods = parse_define(define_path)
692
+
693
+ xpt_files = find_xpt_files(study)
694
+ xpt_vars, xpt_rows = {}, {}
695
+ for stem, path in sorted(xpt_files.items()):
696
+ try:
697
+ names, nrows = read_xpt(path)
698
+ except Exception as e: # noqa: BLE001 - report and continue
699
+ print("warning: could not read %s: %s" % (path, e), file=sys.stderr)
700
+ continue
701
+ xpt_vars[stem] = names
702
+ xpt_rows[stem] = nrows
703
+
704
+ pred = resolve_predecessors(datasets, variables, methods)
705
+ gaps = detect_gaps(datasets, variables, methods, pred, xpt_vars)
706
+ edges = sorted({(v, r) for v, p in pred.items() for r in p["refs"]})
707
+
708
+ with open(args.out, "w", encoding="utf-8") as f:
709
+ f.write(build_html(os.path.basename(os.path.abspath(study)),
710
+ os.path.basename(define_path),
711
+ datasets, variables, methods, pred, edges, gaps,
712
+ xpt_vars, xpt_rows))
713
+
714
+ # ---- console summary ----
715
+ print("Study: %s" % study)
716
+ print("define.xml: %d datasets, %d variables, %d methods"
717
+ % (len(datasets), len(variables), len(methods)))
718
+ for stem in sorted(xpt_vars):
719
+ print(" XPT %-8s %3d vars %4d rows"
720
+ % (stem, len(xpt_vars[stem]), xpt_rows[stem]))
721
+ print("Lineage edges: %d" % len(edges))
722
+ print("Gaps: %d" % len(gaps))
723
+ for g in gaps:
724
+ print(" [%s] %s.%s -- %s"
725
+ % (g["code"], g["dataset"], g["variable"], g["detail"]))
726
+ print("Report written to %s" % args.out)
727
+
728
+
729
+ if __name__ == "__main__":
730
+ main()