Skip to content

feat(ddi)!: a codebook gives back its whole form; round trips through LimeSurvey (#160) - #162

Merged
jstet merged 1 commit into
mainfrom
feat/ddi-lossless
Sep 27, 2026
Merged

jstet merged 1 commit into
mainfrom
feat/ddi-lossless

Conversation

@jstet

@jstet jstet commented Sep 27, 2026

Copy link
Copy Markdown
Member

Closes #160.

What a codebook carries now

ddi2xlsform used to lose several things. They are now in the codebook, as standard DDI where it has an element and a cdl: note otherwise (convention:ddiFields):

was lost now
list names cdl:list, when the list isn't named after the question
or_other shorthand cdl:or_other on the pair's varGrp: shorthand or added (LimeSurvey other=Y); a select_multiple pair's authored other label: cdl:other_label
note names, merged consecutive notes cdl:note_names next to the preQTxt
intro/outro position cdl:position on stdyDscr (in=<group> after=<item>)
a group holding only notes a section varGrp without members, placed by cdl:position
a note's hint, relevant, appearance cdl:row_hint, cdl:row_relevant, cdl:row_appearance
metadata rows, matrix header rows cdl:row (the type cell) + cdl:row_label
language names (label::Deutsch (de)) and order cdl:language
settings other than title/id/version/style a cdl:setting for every setting, default_language as authored
a range bound equal to the default cdl:parameters as authored (valrng stays)

Remaining losses are listed in src/pipelines/ddi2xlsform/README.md:

  • unregistered rows (calculate)
  • columns the model doesn't lift
  • a group without a label
  • whitespace and cell spellings

New round trips

Every fixture (29 forms, 28 TSVs), plus generated forms (fast-check, 200 per property; FT_ROUNDTRIP_RUNS). Locally I also ran 10 000 per property.

  • DDI → XLSForm → DDI gives the same bytes.
  • XLSForm → DDI → XLSForm → LimeSurvey gives the same TSV bytes as XLSForm → LimeSurvey.
  • LimeSurvey TSV → DDI → Instrument equals the TSV's own Instrument.
  • XLSForm → LimeSurvey → DDI → XLSForm equals XLSForm → LimeSurvey → XLSForm.
  • XLSForm → DDI → Instrument, and → XLSForm, compared with a much stricter canonical form. It now compares list names, note names and places, orOther, metadata rows, language names and all settings.

Fixed along the way

The new tests found these bugs:

  • LimeSurvey exclude_all_others never reached the DDI.
  • LimeSurvey → DDI dropped style.
  • The shared XLSForm emitter added field-list for style: pages in the DDI path too. That inference now lives in the LimeSurvey parser.
  • The XLSForm emitter expanded or_other even when the type cell keeps the shorthand.

Breaking

  • lstsv2ddi now declares codeBook/@xml:lang for single-language surveys too. As a result, the universe prose is in the survey's language.
  • extractVariables / variablesFromInstrument also return rows without data, marked with Variable.row.
  • The settings from instrumentFromLstsv no longer include default_language; defaultLanguage still does.
  • In a CDL codebook, identical category sets are no longer merged into one list.
  • The DDI has new cdl: note types, and section varGrps with no members.

Consumers: the formtransform-app DDI view should ignore typed cdl: notes and tolerate empty sections.

Checks

  • vitest: 1464 passed
  • pytest validation + codegen: all pass, 3 xfail as before. This includes XSD, Schematron (with new mutation tests for study-level notes) and pyxform on every ddi2xlsform.json.
  • worker gradle test, npm run validate and the drift check pass.

🤖 Generated with Claude Code

… LimeSurvey (#160)

A CDL codebook now carries what ddi2xlsform used to lose, in cdl: notes
where DDI 2.5 has no element (convention:ddiFields):

- cdl:list: a select's list name, when it isn't the question's
- cdl:or_other: an added other pair, `shorthand` (the type cell's or_other)
  or `added` (LimeSurvey's other=Y); cdl:other_label for a select_multiple
  pair's authored other label
- cdl:note_names: the note rows a preQTxt joins
- stdyDscr: cdl:row (+ cdl:row_label) for rows without data (metadata rows,
  matrix headers), cdl:row_hint / row_relevant / row_appearance for note
  rows, cdl:position for orphan notes, rows and groups of notes only
  (which now get a section varGrp)
- cdl:setting for every setting, default_language as authored;
  cdl:language for the form's language names and order
- a range's start/end as authored in cdl:parameters

The LimeSurvey TSV → DDI path declares the survey's language
(codeBook/@xml:lang), passes its settings (style), and carries
exclude_all_others onto the choices. format=G's field-list groups are
inferred by the LimeSurvey parser, not the shared XLSForm emitter.

New round trips, on every fixture and on generated forms (fast-check):
DDI → XLSForm → DDI is byte-identical; XLSForm → DDI → XLSForm →
LimeSurvey equals XLSForm → LimeSurvey byte for byte; LimeSurvey → DDI →
Instrument equals the TSV's own; XLSForm → LimeSurvey → DDI → XLSForm
equals XLSForm → LimeSurvey → XLSForm. The model comparison is strict now.

BREAKING CHANGE: lstsv2ddi declares codeBook/@xml:lang for a
single-language survey too, so its universe prose is in the survey's
language. extractVariables / variablesFromInstrument also return rows
without data (Variable.row set). instrumentFromLstsv's settings no longer
hold default_language (defaultLanguage does). A CDL codebook's identical
category sets are no longer merged into one list. New cdl: note types and
section varGrps without members appear in the DDI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jstet
jstet merged commit 95c72d3 into main Sep 27, 2026
4 checks passed
@jstet
jstet deleted the feat/ddi-lossless branch September 27, 2026 18:14
jstet added a commit that referenced this pull request Sep 27, 2026
…constraint (#160) (#163)

Closes the small losses #162 left:

- constraint_message is kept without a constraint
- cdl:required holds the cell as authored (TRUE, true), yes by default
- cdl:parameters holds the cell as authored, guidance_hint included
  (ivuInstr still has it), not only the parameters DDI has no element for
- cdl:no_label marks a group without a label (its txt stays its name)
- notes whose joined preQTxt can't be split at blank lines (a blank line
  in one, a language only some have) keep their own text in cdl:row_label
- the form model's languages come from every text column, not only label:
  a language only a hint has is no longer dropped

The generated forms cover all of these.

BREAKING CHANGE: cdl:parameters now includes a guidance_hint, and
cdl:constraint_message appears without a constraint. ddi2xlsform gives
back a guidance_hint inside parameters as it was, which pyxform rejects
when it has spaces (hints_survey, as its source).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DDI round trip: close the documented losses, round trips through LimeSurvey

1 participant