repo <- file.path(tempdir(), "data_check_demo")
dir.create(file.path(repo, "data"), recursive = TRUE, showWarnings = FALSE)
study <- data.frame(
participant_id = 1:20,
condition = rep(c("control", "treatment"), each = 10),
age = sample(18:65, 20, replace = TRUE),
rt = round(runif(20, 300, 900)),
score = round(rnorm(20, 50, 10), 1),
passed = sample(c(0, 1), 20, replace = TRUE)
)
utils::write.csv(study, file.path(repo, "data", "study.csv"), row.names = FALSE)
writeLines("# analysis\nd <- read.csv('study.csv')",
file.path(repo, "analysis.R"))23 Data Check
This module is currently under development, and not available in the main version of Metacheck. To try it out, install the development version of Metacheck:
remotes::install_github("scienceverse/metacheck@data_check")23.1 What it checks
The data_check module is the foundation for all of Metacheck’s data-oriented checks. It takes the files shared with a paper — from a linked repository or a local folder — and does three things:
- Classifies every file into a semantic type: tabular data, code, a codebook, a README, software, output, supplemental, an asset (image/audio/video/stimulus), or other.
- Reads each readable tabular data file and describes every column along several independent facets — how it is stored, its measurement level, what it measures, how it functions, and its unit — together with basic summary statistics and an inferred unit of observation.
-
Makes the extracted columns and read data frames available to the other data modules —
data_validate,excel_check,codebook_check, andpsychds_checkall build ondata_check’s output rather than re-reading the files, andconvert_psychds()uses its classification and study grouping to build a Psych-DS archive.
On its own, data_check gives you a machine-readable inventory of what was shared and what each data column contains. It is also the first module in the data pipeline: run it once, and the downstream modules reuse its results.
This chapter runs entirely offline. We build small example repositories in a temporary folder and point data_check at them with local_path. The same calls work against a real downloaded repository — see the Local Files chapter.
23.2 A worked example
We create a tiny study folder with one data file and a code file:
Now run the module. test_paper() supplies a minimal paper object with no repository links, and local_only = TRUE means only the local folder is inspected.
mo <- module_run(test_paper(), "data_check",
local_path = repo, local_only = TRUE)
mo$traffic_light#> [1] "green"
cat(mo$summary_text)#>
#> - We classified 2 files: 1 data, 1 code.
#> - We found 1 tabular data file and extracted 6 columns from 1 of them.
23.3 File classification
The first thing data_check produces is a classification of every file it found, available in the structure element:
| file_name | data_type | data_format |
|---|---|---|
| analysis.R | code | NA |
| study.csv | data | tabular |
Every file is sorted into one of nine semantic types. The vocabulary is data, codebook, code, software, output, supplemental, readme, asset, and other:
| Type | Examples |
|---|---|
data |
.csv, .tsv, .xlsx, .sav, .dta, .rds, .jasp, .omv, and binary scientific formats (.h5, .parquet, .npy) |
code |
.R, .Rmd, .qmd, .py, .do, .sps, .sas, .ipynb
|
codebook |
files whose name looks like a data dictionary, plus a Qualtrics .qsf survey definition |
readme |
README, read me
|
software |
executables and installers (.exe, .dll, .sh), config files |
output |
rendered results (.spv, figures) |
supplemental |
documents, slides, web pages, and preregistrations |
asset |
images, audio, video, fonts (stimuli/materials) |
other |
anything no rule places (an LLM may reclassify these) |
23.3.1 How the classifier works: data_classify_files()
Classification is done by the exported helper data_classify_files(), which layers three rule sets. Understanding the order matters because later layers override earlier ones:
-
Name-based rules (
file_category()): filenames matching README or codebook patterns win first, before any extension is consulted. A file literally calledREADME.txtis a readme even if it is delimiter-rich. -
An extension crosswalk built on metacheck’s coarse
file_typestable (.file_type_crosswalk): mapscode → code,stats → code(SPSS/SAS/Stata syntax),exec/config → software,audio/video/image/3D/font → asset,book/slide/text/web → supplemental,archive → other. -
Format-locked extension overrides (
.fixed_ext_type, highest priority): a fixed table that corrects any coarser guess. This is where.qsfbecomes acodebook(a Qualtrics survey definition is its own codebook), scientific binaries like.npy/.h5/.parquet/.featherbecomedata, and trial-level task exports (.iqdat,.edat,.edat2) becomedataso they are actually fetched.
Two special cases run last. A file named README is forced to readme as belt-and-braces. A preregistration — a name matching prereg / pre-reg / preregistration (as a whole token, so preregional does not match) — is reclassified to supplemental so it lands in documentation/ rather than being misfiled as a data CSV or as code. A genuine analysis script named after the prereg keeps its code type.
data_classify_files(c("data.csv", "analysis.R", "README.md",
"codebook.xlsx", "survey.qsf", "preregistration.pdf"))[1] "data" "code" "readme" "codebook"
[5] "codebook" "supplemental"
23.3.2 Tabular vs raw: data_format()
For files classified as data, data_check records a second field, data_format, which is "tabular" or "raw". This is decided by the exported helper data_format() against a list of raw extensions (EEG/physiological, neuroimaging, motion capture, array formats, eye-tracking, audio/video, and generic binaries/documents). Only tabular files are read for column extraction; raw data files are classified and listed but never parsed as a table. Unknown extensions fall back to tabular.
data_format(c("csv", "edf", "mp4", "sav", "nii"))[1] "tabular" "raw" "raw" "tabular" "raw"
23.4 The data tree
To make the layout of a repository easy to see at a glance, data_check’s report includes a data tree — the folder structure of each repository drawn as an indented tree (repo_tree_lines() / repo_tree_block(), internal to the module). Directories are listed before files and both are sorted alphabetically, so the same repository always produces the same tree. In the rendered report the tree appears inside a collapsible “Data Tree” callout, one per repository when several are linked.
#> ├── data/
#> │ └── study.csv
#> └── analysis.R
#> Unit = c("years", "", "", "", "milliseconds", ""), Rows = c(20L,
#> 20L, 20L, 20L, 20L, 20L), "% Missing" = c(0, 0, 0, 0, 0,
#> 0), "N Unique" = c(12L, 2L, 20L, 2L, 20L, 20L), Mean = c(38.55,
#> NA, NA, NA, 575.25, 52.79), SD = c(14.373, NA, NA, NA, 183.915,
#> 9.34), Min = c(18, NA, NA, NA, 320, 38.8), Max = c(62, NA,
#> NA, NA, 876, 73.3)), row.names = c("age", "condition", "participant_id",
On a real repository this is often the quickest way to spot problems: data scattered across many ad-hoc folders, deeply nested paths, or files sitting loose at the top level. This same tree is the starting point for the Psych-DS Check, which compares a repository’s actual tree against the tree the Psych-DS standard says it should have.
23.5 Column facets
For every readable tabular file, data_check reads the data and describes each column. Earlier versions gave each column a single col_type value — but that conflated things that are not really alternatives: how a value is stored, what level it is measured on, and what it actually measures. Following the DDI metadata standard, data_check now describes each column with several orthogonal facets, computed by the exported helper data_col_facets():
| column_name | representation | measurement_level | concept | role | unit | |
|---|---|---|---|---|---|---|
| participant_id | participant_id | text | nominal | id | identifier | NA |
| condition | condition | text | nominal | condition | condition | NA |
| age | age | numeric | ratio | age | measure | years |
| rt | rt | numeric | ratio | reaction_time | measure | milliseconds |
| score | score | numeric | ratio | NA | measure | NA |
| passed | passed | numeric | nominal | NA | measure | NA |
The facets are:
| Facet | Values | What it captures (DDI analogue) |
|---|---|---|
representation |
numeric, text, datetime, code, empty
|
how the value is stored (RepresentedVariable representation) |
measurement_level |
nominal, ordinal, interval, ratio
|
the Stevens level (DDI @classificationLevel) |
concept |
reaction_time, accuracy, age, gender, race, likert, condition, id, date, timestamp, … |
what the column measures (DDI Variable → Concept) |
role |
identifier, measure, condition, timestamp
|
how it functions in the dataset (DDI VariableRole) |
unit |
seconds, milliseconds, years, … |
the unit of measure (DDI UnitType) |
quality |
ok, empty, constant
|
a data-quality state, kept separate from type |
parse_note |
NA, comma_decimal, mostly_numeric
|
a representation quirk (was a fake col_type) |
Because the facets are independent, an age column is numeric and ratio and the age concept and measured in years — each fact recorded in its own field rather than crammed into one label. A participant_id is text with the identifier role and the id concept; a Likert item is numeric, ordinal, with the likert concept.
23.5.1 How facets are derived
data_col_facets() builds on two lower-level pieces:
-
data_col_type()— the rules-only “primitive”. It classifies a column in a fixed order: all-NA →empty; an ID name pattern →id; one unique value →constant; two unique →binary; ≥70% date-parseable →date; median string length > 40 →text; numeric with non-integers or >20 unique →continuous; an ambiguous integer (3–20 unique) is flaggedambiguous = TRUEfor the LLM; comma-decimal text ("3,14") →continuous_comma_decimal/continuous_outliers_excluded. It also returnsnumeric_values(a clean numeric vector for statistics) andis_numeric. This primitive is deliberately kept internal so its battle-tested edge cases survive. -
data_col_concept()— the content classifier for the concept facet, using name-and-value agreement: a column is only tagged (say)reaction_timewhen its name looks like a reaction time (.concept_is_rt()) and its values are plausible non-negative durations. It detectsreaction_time,accuracy(.concept_is_accuracy()— a 0/1 or true/false column namedacc/correct/hit/error), the three demographic concepts viadata_check_demographic(),timestamp(.concept_is_timestamp()— a datetime name whose values carry anHH:MMcomponent), andcondition(.concept_is_condition()— a name-drivencondition/group/treatment/arm).
data_col_facets() then untangles the primitive’s col_type into representation + measurement_level (via .coltype_to_facets()), assigns the role (an id column is an identifier, a datetime is a timestamp, a condition concept is a condition, everything else is a measure), fills structural concepts (id, date, timestamp) that follow from other facets, and detects a likert concept from an ordinal-looking ambiguous integer. It seeds a unit for the two concepts that imply one: reaction_time becomes milliseconds when the median value is ≥100 else seconds, and age becomes years.
# RT: representation "numeric", concept "reaction_time", unit "milliseconds"
# subject_id: representation "text", role "identifier", concept "id"
Concepts detected by rules keep the whole rule-based path fully offline and deterministic. When you enable an LLM (see below), it fills in the concept and measurement_level for columns the rules left blank — for example a reaction time hidden behind a cryptic name like q3.
The old single col_type field has been replaced by these facets. If you have code that read mo$table$col_type, read the relevant facet instead — most often representation (numeric vs text) or concept.
23.5.2 Demographic detection: data_check_demographic()
The three demographic concepts — age, gender/sex, race/ethnicity — are detected by the exported helper data_check_demographic(), which is the single rule reused by data_col_concept() (here), by the facet model, and by data_validate’s demographic inventory. It requires the column name and values to agree: an age column is only tagged when its name looks like age and its values fall in a plausible human range, so a condition column coded 1/2 is never mistaken for gender.
23.6 Summary statistics: data_col_stats()
Alongside the facets, data_col_stats() computes a one-row block of statistics for each column: n, n_missing, n_unique, mean, sd, se, median, min, max, range, p25, p75, iqr, skewness, and kurtosis. Non-numeric or empty columns return n/n_missing/n_unique with the numeric statistics as NA. These feed the report’s Descriptives Overview (one table per source file) and are carried into data_validate and the Psych-DS variableMeasured block.
23.7 Unit of observation: data_analysis_unit()
data_check also infers, for each data file, what one row represents — a person, a trial, a session, or a dyad (DDI’s analysisUnit) — with the exported helper data_analysis_unit(). This matters because it tells a reviewer whether rows are independent (one per participant) or nested (many trials per participant), which changes how the data should be analysed.
The rule reads the identifier column(s) and their uniqueness:
-
two or more identifier columns (or a dyad/partner column) →
dyad; -
one identifier, unique per row (≥98%) →
person; -
one identifier that repeats, alongside a
trial/itemcolumn →trial(long format); alongside asession/wavecolumn →session(repeated measures); otherwise →trial; -
no identifier but a trial or session column present →
trial/session.
| source_file | analysis_unit | |
|---|---|---|
| participant_id | study.csv | person |
Each row of our study.csv is one participant, so the file is person-level. When a repository mixes units — say a person-level file next to a trial-level file — the report flags it, because combining files at different levels without aggregating is a common and easily-missed error.
23.8 Study groups
A single repository often bundles several studies (Experiment 1, Study 2a, a pilot). data_check assigns every analysable file a study group (ex1, ex2a, pilot1, …, or shared) so a multi-study repository can be split into self-contained Psych-DS datasets by convert_psychds(). This is done by data_group_llm(), but — despite the name — it is deterministic wherever the evidence allows, and only falls back to the model for files nothing else can place. Four passes run in order, each more specific overriding the last:
-
By source repository (
.data_group_from_repo()): a paper that links several repositories with different files is multi-study, one study per repository. Repositories whose file sets overlap ≥90% (Jaccard) are treated as mirrors of one study, not two. -
By path (
.data_group_from_path()): a filename or folder that names its study outright —Experiment 1/,study2a_data.csv, even smashed-together names like…experiment1creplication…— is grouped by a regex that reads such names more reliably than a small LLM. -
By code reference (
.data_code_refs()): a script names the data it reads and writes (read_csv("raw/x.csv")), which is hard evidence that the script and those files belong to the same study. This rescues data files whose own path only names a processing stage (raw/,processed/). -
By LLM (only when
llm_use(TRUE)): the last resort, seeing only files the deterministic passes could not place. When those placed everything — the common multi-repo case — no call is made at all.
Two guards make the result trustworthy. data_study_roster() reads the manuscript for the studies it names (“Experiment 1, 2a, 3”) — the authoritative list — and relabels slot-named groups to the authors’ labels when the counts match; .data_group_check_roster() reports any disagreement rather than guessing. And a data file is never shared: data always belongs to a study, so any data file the passes left shared falls back to its repository’s study or to the sole study. Without an LLM, files that no deterministic pass can place stay NA (unknown), and psychds_check reports that subgrouping could not be detected.
23.9 Reading files: format tolerance
data_check reads tabular files with data_read_head() (n_rows = Inf reads the whole file), which is deliberately tolerant of the messy encodings and formats research data actually ships in:
-
Delimited text (
.csv/.tsv/.txt/.dat): the delimiter is sniffed (.sniff_delimiter()) and header presence auto-detected (.detect_header()— a file whose first two rows are both all-numeric is treated as headerless). Reading usesdata.table::freadwhen available (orders of magnitude faster on awkward quoted fields) and falls back toread.delim. -
A “single big field” blob (a JSON/XML document dumped under one header) is detected from the first two lines (
.is_single_field_blob()) and skipped — it is not a table, and parsing it is pathologically slow. -
Excel (
.xlsx/.xls) viareadxl; SPSS/Stata/SAS (.sav/.dta/.sas7bdat) viahaven(preserving value labels and declared missing codes forcodebook_check); JASP (.jasp) and jamovi (.omv) return a labelled data frame like a.sav. -
R objects:
.rdsyields its data frame; an.RData/.rdaworkspace is restored in an isolated subprocess (.read_rdata_isolated()) because loading a saved model that references an uninstalled package can print C-level diagnostics or crash. A workspace that holds no reusable data frame (only fitted models / session state) returnsNULLand is reported as a data-sharing recommendation. -
Invalid UTF-8 bytes (a Latin-1/Windows-1252 export) are re-interpreted as Latin-1 rather than failing (
.utf8_repair_df()), and the per-column repair count is recorded in autf8_repairedattribute sodata_validatecan warn about the file’s mixed encoding.
Qualtrics exports get special handling on read (see below).
23.10 What the module holds back or reclassifies after reading
Several file types read as a data frame but are not straightforward datasets. data_check handles each so they are neither silently dropped nor wrongly treated as data:
-
File manifests (
data_is_manifest()): a “table of contents” CSV whose cells name other files in the repository. Detected by content (≥80% of a column’s values resolve to repo files, across ≥2 distinct extensions, so a genuinestimuluscolumn of images does not trip it), then demoted tosupplemental. - R workspaces with no reusable data: reported with advice to share the underlying data as CSV + codebook.
-
Coding worksheets (
.tabular_usable()): a human-formatted worksheet (mostly free-text annotation columns, mostly empty cells) reads as a data frame but is not a rectangular dataset. Detected from the already-computed facets using a prose fraction and a missingness fraction with tiered rules (overwhelmingly free text, or moderately free-text and mostly empty). Columns are not extracted and nothing is sent to the LLM, but the file stays classified asdatasoexcel_checkstill inspects its formatting. Crucially, high missingness alone never excludes — a branched/planned-missing survey is 90%+ missing but is real numeric data. -
.txtreclassification (txt_classify_content()): a.txtis ambiguous by name (an E-Prime export, a task log, a codebook, or prose all ship as.txt), so all.txtfiles are fetched underdownload = "data"and reclassified from their content once on disk. Only ever an upgrade todata(an E-Prime header block, or a delimited table with a real header row); an unrecognised.txtkeeps whatever its name implied, so prose is never mistaken for data. A name-based README/codebook verdict is authoritative and never overridden. -
Trial-level files (
.bh_is_trial_level_file()): E-Prime, Inquisit, jsPsych, and native Behaverse files publish one file per participant per block. Treating each as its own dataset would produce hundreds of fragmented “datasets” for one instrument, so they are held out of the tabular extractor, recorded, and later merged per instrument intoparadata/<instrument>.jsonbyconvert_psychds(), following the Behaverse Data Modeltrialstandard (a source-agnostic, tidy, trial-level schema). Nothing is deleted. The Behaverse schema is described in full in the Psych-DS Check chapter.
23.11 Qualtrics survey exports
Qualtrics CSV/TSV exports have a fixed, recognisable shape: a set of reserved response-metadata columns (StartDate, Duration (in seconds), Finished, ResponseId, …) and, for the “use choice text” export, extra header rows (question text and an ImportId JSON row) as the first data rows, which force every column to text. data_check detects these files with data_check_is_qualtrics() (matching ≥4 reserved metadata names and/or the ImportId row), strips the extra header rows with data_strip_qualtrics_header() so the columns type correctly, and tags each metadata column with its semantic role via .qualtrics_tag_cols(). The reserved-name → tag map is the schema .qualtrics_meta_cols (startdate → qualtrics_start, durationinseconds → qualtrics_duration, finished → qualtrics_finished, …), matched case-insensitively after stripping non-alphanumerics so Duration (in seconds) and the R-mangled Duration..in.seconds. both hit. The substantive question columns are deliberately not interpreted here — that is the job of data_validate (metadata) and the scale-block detection in codebook_check.
23.12 What you get back
| Element | What it contains |
|---|---|
$traffic_light |
"green" when data files were read; "yellow" when tabular files exist but some had no local copy; "na" when there is nothing to check |
$summary_text |
plain-text bulleted summary: files classified, columns extracted, study groups, and any held-back files |
$summary_table |
per-paper counts (data_file_n, column_n, empty_col_n, plus wide per-representation counts) |
$table |
one row per column, with the facets, analysis_unit, sample_values, utf8_repaired, and summary statistics |
$structure |
one row per file, with data_type, data_format, group, and tabular_usable / non_tabular_reason
|
$previews |
the full data frames that were read (consumed by data_validate) |
$gated_repos |
repositories found but not listable (size-gated GitHub, private OSF) |
$manifest_path |
path to a written JSON manifest, when one was requested |
The $structure and $previews elements are what the downstream modules consume, which is why you rarely need to run data_check by itself — running data_validate, codebook_check, psychds_check, or convert_psychds() triggers it automatically and reuses the result.
23.13 Downloading files
When a paper links to an online repository, data_check downloads the files it needs into a shared on-disk cache (reused across runs and across modules, so a file is never fetched twice — see Caching and Reuse). The download argument controls what is fetched:
-
download = "data"(the default) fetches only the machine-readable files the checks analyse — tabular data plus codebook/README files, and every.txt(which is reclassified from content afterwards). -
download = "all"fetches every file in the repository (code, materials, PDFs, assets, …). This is the right choice when building a complete data archive withconvert_psychds(). -
download = "none"(orFALSE) downloads nothing — files are only classified by name.TRUEis accepted as a synonym for"data".
# default: fetch only the readable data + codebook/README files
module_run(paper, "data_check")
# fetch everything, for building a full data archive
module_run(paper, "data_check", download = "all")23.13.1 Caching downloads
By default (cache = FALSE) downloads go to a temporary directory discarded when the R session ends, so nothing accumulates on disk. Pass cache = TRUE to keep files in a persistent on-disk cache (repo_cache_dir()) reused on later runs — the right choice when repeatedly checking the same repositories or building an archive across sessions. Clear it with repo_cache_clear().
23.13.2 Size caps
Two caps bound how much is downloaded per repository, and they work as an upfront, all-or-nothing gate:
-
max_file_size(default 100 MB): if any single file in a repository exceeds this, the whole repository is refused (nothing downloaded), with a message naming the size to lift it. -
max_download_size(default 500 MB): if a repository’s total exceeds this, the whole repository is refused.
Set either to Inf for no cap. A file whose size cannot be determined at all also causes its repository to be skipped, so nothing huge is ever fetched blind. Every skip is reported inline ($gated_repos), so you can raise the relevant cap and re-run.
For GitHub specifically, github_gate (default TRUE, or NULL = “gate unless download = "all"”) refuses to recursively list a repository above github_max_files (default 1000) files before it is walked.
module_run(paper, "data_check", download = "all",
max_file_size = 200, max_download_size = 2000)23.13.3 Skipping asset types, and peeking inside zips
When building an archive you often want the data but not the stimuli or media — those are usually better linked to than mirrored. Two options handle this:
-
skip_types— a vector ofdata_types never to download, even underdownload = "all". Most usefullyskip_types = "asset"leaves out stimuli/media. Skipped files are still listed (with the reason) in the manifest. -
peek_zips = TRUE— before downloading a.zip, look inside it via an HTTP range request (fetching only the zip’s tail, not the whole file —zip_peek()/zip_decision()) and only download zips that actually contain data or a codebook. Downloaded data-zips are unpacked (.expand_zip()) and their inner data files added to the archive, with the zip itself demoted to a container; inner assets are dropped.
# archive-building recipe: everything except media, peeking inside zips
module_run(paper, "data_check", download = "all",
skip_types = "asset", peek_zips = TRUE)23.13.4 A file manifest
Pass manifest = "some/dir" to write a per-paper JSON manifest (.data_check_write_manifest()) listing every repository file with its URL, size, type, Psych-DS target path, and whether it was downloaded (and if not, why). It records this provenance in a self-describing block whose field names map onto DDI-Codebook 2.5 elements (provenance$ddi_mapping), the metacheck/R version and platform, and — when the LLM was used — the model. Files not downloaded are split into intentional (a policy decision — download mode, skip_types, the size caps — where re-running changes nothing) and unintentional (wanted but not fetched: a transient failure or a missing URL — the re-run signal, flagged rerun_recommended). Unsized files are resolved with a lightweight HEAD request so the manifest carries real sizes — useful for auditing a corpus or choosing size caps before a large archive build.
module_run(paper, "data_check", download = "none", manifest = "manifests")23.14 Using an LLM
With llm_use(TRUE), data_check adds things the rules cannot do reliably. The LLM path is layered on top of the rules — it only fills gaps, never overrides a confident rule:
-
File types for files the rules left
other(.llm_classify_batched()classifies intocodebook/software/output/supplemental/asset/other). -
Concepts for cryptically-named columns — the rules only tag a concept when the name gives it away, so a reaction time called
q3stays blank; the LLM fills it in. - Refined measurement levels for ambiguous integer columns (nominal vs ordinal vs ratio).
- Study groups for files no deterministic pass could place (see above).
All LLM classification runs through .llm_classify_batched(), which sends numbered listings and expects index-mapped {index, value} results back, so a dropped or reordered answer never misaligns the others. A header-signature dedup means that in a repository of pp1.csv … pp30.csv (all sharing a schema), an ambiguous column is classified once and broadcast to the identical-header files, turning 30 repeated questions into one.
llm_use(TRUE)
llm_model("groq/openai/gpt-oss-20b")
mo <- module_run(test_paper(), "data_check",
local_path = repo, local_only = TRUE)
mo$table$concept # filled in for cryptic names
mo$structure$group # study groupingSee the LLMs chapter for how to configure a model.
23.15 Building a Psych-DS archive: convert_psychds()
data_check’s classification, facets, study groups, and read data frames are what make it possible to rebuild a repository as a standard-compliant dataset. That is the job of convert_psychds(), which is documented in full — together with the Psych-DS, OpenScales OSD, and Behaverse schemas it writes — in the Psych-DS Check chapter. In short: run data_check with download = "all" (so every file is on disk), then convert_psychds(paper) copies each file to its standard Psych-DS location, generates a dataset_description.json (with variableMeasured built from these facets and the codebook labels), splits multi-study repositories into study-<group>/ datasets under a collection.json, writes identified scales as scales/<code>.osd files, and normalises trial-level data into paradata/<instrument>.json.
