conceptql 0.4.1 → 0.6.0

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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +498 -0
  3. data/config/vocabularies.csv +85 -87
  4. data/config/vocabulary_formats.sha +1 -0
  5. data/config/vocabulary_formats.tsv +39 -0
  6. data/doc/spec.md +2091 -0
  7. data/lib/conceptql/behaviors/metadatable.rb +15 -3
  8. data/lib/conceptql/behaviors/provenanceable.rb +82 -8
  9. data/lib/conceptql/behaviors/utilizable.rb +5 -3
  10. data/lib/conceptql/cli.rb +1 -1
  11. data/lib/conceptql/data_model/base.rb +3 -171
  12. data/lib/conceptql/data_model/gdm.rb +3 -1
  13. data/lib/conceptql/data_model.rb +20 -4
  14. data/lib/conceptql/database.rb +9 -2
  15. data/lib/conceptql/date_adjuster.rb +12 -5
  16. data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
  17. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
  18. data/lib/conceptql/lexicon/lexicon_strategy.rb +58 -4
  19. data/lib/conceptql/lexicon.rb +218 -2
  20. data/lib/conceptql/nodifier.rb +7 -1
  21. data/lib/conceptql/one_query_counts.rb +120 -0
  22. data/lib/conceptql/operators/co_reported.rb +4 -31
  23. data/lib/conceptql/operators/count.rb +4 -0
  24. data/lib/conceptql/operators/death.rb +1 -1
  25. data/lib/conceptql/operators/dedup.rb +1 -1
  26. data/lib/conceptql/operators/episode.rb +10 -2
  27. data/lib/conceptql/operators/ethnicity.rb +0 -4
  28. data/lib/conceptql/operators/except.rb +5 -0
  29. data/lib/conceptql/operators/filter.rb +12 -2
  30. data/lib/conceptql/operators/hospice.rb +2 -3
  31. data/lib/conceptql/operators/hospitalization.rb +2 -3
  32. data/lib/conceptql/operators/information_periods.rb +5 -4
  33. data/lib/conceptql/operators/invalid.rb +7 -1
  34. data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
  35. data/lib/conceptql/operators/numeric.rb +51 -14
  36. data/lib/conceptql/operators/occurrence.rb +16 -4
  37. data/lib/conceptql/operators/one_in_two_out.rb +1 -2
  38. data/lib/conceptql/operators/operator.rb +66 -30
  39. data/lib/conceptql/operators/person.rb +1 -1
  40. data/lib/conceptql/operators/person_filter.rb +5 -0
  41. data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
  42. data/lib/conceptql/operators/provenance.rb +13 -4
  43. data/lib/conceptql/operators/provider_filter.rb +5 -1
  44. data/lib/conceptql/operators/race.rb +2 -10
  45. data/lib/conceptql/operators/read.rb +1 -100
  46. data/lib/conceptql/operators/recall.rb +1 -1
  47. data/lib/conceptql/operators/snf.rb +3 -4
  48. data/lib/conceptql/operators/sum.rb +46 -5
  49. data/lib/conceptql/operators/temporal_operator.rb +6 -2
  50. data/lib/conceptql/operators/trim_date.rb +52 -6
  51. data/lib/conceptql/operators/trim_date_end.rb +11 -1
  52. data/lib/conceptql/operators/trim_date_start.rb +11 -1
  53. data/lib/conceptql/operators/union.rb +45 -1
  54. data/lib/conceptql/operators/vocabulary.rb +21 -4
  55. data/lib/conceptql/paths.rb +16 -0
  56. data/lib/conceptql/query.rb +78 -24
  57. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
  58. data/lib/conceptql/rdbms/generic.rb +21 -1
  59. data/lib/conceptql/rdbms/spark.rb +12 -0
  60. data/lib/conceptql/scope.rb +187 -24
  61. data/lib/conceptql/version.rb +1 -1
  62. data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
  63. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
  64. data/lib/conceptql/vocabularies/entry.rb +15 -23
  65. data/lib/conceptql/vocabularies/formats.rb +59 -0
  66. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +8 -16
  67. data/lib/conceptql/window/table.rb +12 -3
  68. metadata +9 -14
  69. data/lib/conceptql/data_model/omopv4_plus.rb +0 -12
  70. data/lib/conceptql/operators/concurrent_within.rb +0 -69
  71. data/lib/conceptql/query_modifiers/omopv4_plus/admission_date_query_modifier.rb +0 -45
  72. data/lib/conceptql/query_modifiers/omopv4_plus/drug_query_modifier.rb +0 -80
  73. data/lib/conceptql/query_modifiers/omopv4_plus/pos_query_modifier.rb +0 -47
  74. data/lib/conceptql/query_modifiers/omopv4_plus/provenance_query_modifier.rb +0 -45
  75. data/lib/conceptql/query_modifiers/omopv4_plus/provider_query_modifier.rb +0 -26
  76. data/lib/conceptql/vocabularies/behaviors/costish.rb +0 -24
  77. data/lib/conceptql/vocabularies/behaviors/omopish.rb +0 -32
  78. data/schemas/omopv4_plus.yml +0 -729
  79. data/schemas/omopv4_plus_more_ignorables.tsv +0 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1958ad620e733aede026fc74e0c21f06a2376031b514d6a7736ef287eda9afc5
4
- data.tar.gz: 0ed440d679a23994a83237d54120eabac40bbcd9d9b7499e8cfff18c076aabd8
3
+ metadata.gz: 03af33e5bd8d7515c78256e11c98fe9aab5bd2501f56fea7db2960acf55c3ea6
4
+ data.tar.gz: 27e02e2f1da7e8acfe3bda3a2a7787327f386716a1d8ddaa16ca371e2798bd1a
5
5
  SHA512:
6
- metadata.gz: f42b9ec69eefd01041c9eab5f3aa421aaf8fc63df0933df1e0ab79a9919f4e4da06f48338c96f6af51226fb567ce15f3355116a962dd757d111efd14bdf262da
7
- data.tar.gz: c50c2a4934b10f457ea25fb317f7844ab98f5816f55ea09aad50f1cb9a24424b567f67abf4ec9585ac677435641db986947b2b78b33f388b4f46a1bdb6f9bcd8
6
+ metadata.gz: 209474ec28b2fa409fa6d0bde640c411d6beaf5bff0c67c346483fc25b59e5465f2ca2fb444376397b1080d492d4a037d21314f0cd206837551807c016058748
7
+ data.tar.gz: 46dca2627d4085ee7c3ad42b821b03c685536cd933ea63987061cbad58bec28c01c07e9dcad48845fe66d5343265d289599f87ef2a6c67a92d61818624f446c3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,504 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2026-11-02
8
+
9
+ ### Added
10
+
11
+ - `provenance` takes keywords of the form `<file keyword>:<code keyword>` again (the form
12
+ existed from 2019 until the lexicon rewrite of 2023). `"inpatient:primary_or_first"` keeps
13
+ the records whose file provenance is in the descendants of `inpatient` AND whose code
14
+ provenance is in the descendants of `primary_or_first`. A plain keyword keeps its meaning:
15
+ it keeps a record when either column is in the descendants of its concept. Several keywords
16
+ in one operator are still ORed, so `["inpatient:primary", "outpatient:first"]` keeps the
17
+ records of either pair. The left half must be a file provenance keyword and the right half
18
+ a code provenance keyword. Any other keyword with a `:` (a swapped pair, an unknown half, an
19
+ empty half or a second `:`) is the validation error `unrecognized keywords`, with the
20
+ keyword as written, and the plain query raises `ConceptQL::InvalidStatementError` for it.
21
+ Before, annotate reported every keyword with a `:` as that error. The test statement
22
+ `provenance/count_2.json` (`carrier_claim:primary_or_first`) is now valid. It has no
23
+ results, because no carrier claim record in the test data has a primary or first code.
24
+ - `provenance` takes the keyword `"*"`. `["provenance", <stream>, "*"]` keeps the records
25
+ whose file provenance or code provenance is any provenance concept, and drops the records
26
+ with neither. It builds the SQL that `provenance` with no keyword built before. Keywords are
27
+ ORed, so `"*"` with other keywords keeps the same records and builds the same SQL as `"*"`
28
+ alone. The other keywords are still checked, so an unrecognized one beside `"*"` is still
29
+ the error `unrecognized keywords`. A `"*"` half in a `<file keyword>:<code keyword>`
30
+ keyword, such as `"inpatient:*"`, is that error too.
31
+ - `place_of_service_filter` takes the code `"*"`. `["place_of_service_filter", <stream>, "*"]`
32
+ keeps the records whose place of service is any concept of the CMS Place of Service
33
+ vocabulary. It drops the records with no place of service, and the records whose place of
34
+ service is a concept of another vocabulary. This is the meaning of `"*"` in `provenance`.
35
+ Codes are ORed, so `"*"` with other codes keeps the same records as `"*"` alone. The other
36
+ codes are still checked, so an unknown code beside `"*"` is still the error
37
+ `unknown code(s)`. Before, `"*"` was itself that error.
38
+ - `ConceptQL::Lexicon.for_dataset(db)` makes a DuckDB dataset its own lexicon. The returned
39
+ Lexicon reads the OHDSI vocabulary tables (`concept`, `concept_ancestor`,
40
+ `concept_relationship` and `vocabulary`) from `db` itself, through its search path, and
41
+ caches its lookups. It never reads `LEXICON_URL`. Pass it as
42
+ `ConceptQL::Database.new(db, lexicon: lexicon)`, without `cache_lookups`. When the dataset
43
+ lacks one of the tables, it raises `ConceptQL::Lexicon::MissingVocabularyError`, whose
44
+ message names the missing tables, the schemas that it searched and the search path. The
45
+ error's `missing_tables` attribute holds the missing tables, and its `schemas` attribute
46
+ holds the searched schemas, each as `catalog.schema`. `for_dataset` returns nil for a
47
+ Postgres or Spark db, and the Database chooses its lexicon as before.
48
+ - `sum` takes the boolean option `group_by_person`. With it, `sum` gives one person result
49
+ for each person (and window): `person_id`, `criterion_domain` `person`, `criterion_table`
50
+ of the person table (as a cast to `person` gives them), `criterion_id` 0, the earliest
51
+ `start_date`, the latest `end_date`, and the sum of `value_as_number`. The other columns
52
+ are NULL. `["sum", ["numeric", 1, <stream>], {"group_by_person": true}]` counts the events
53
+ of each person. Without the option, `sum` groups as before: by every column except the
54
+ dates, `criterion_id` and `value_as_number`, so one person can get several rows. A value
55
+ other than true or false is the validation error `wrong option format`.
56
+ - The operators `ms_drg` and `cms_drg` select DRG events by DRG system. CMS renumbered the
57
+ DRG codes on 2007-10-01, when MS-DRG replaced CMS-DRG, and reused most codes for new
58
+ meanings. An event row does not record which meaning applied, so each operator selects the
59
+ DRG code and the event dates of its system: `ms_drg` selects the events whose `start_date`
60
+ is on or after 2007-10-01, and `cms_drg` the events before that day. The day is the
61
+ constant `ConceptQL::Vocabularies::DrgSystems::MS_DRG_START_DATE`. Both operators look up
62
+ concept IDs in the DRG vocabulary as `drg` does, so they work with the OHDSI and the GDM
63
+ lexicons. They check codes against the DRG format of vocabulary_formats (3 digits) and warn
64
+ about unknown codes as `drg` does. They never give the warning
65
+ `code(s) with more than one meaning`. A code list names each code by its meaning in the
66
+ operator's system when the lexicon keeps OHDSI concept classes (`MS-DRG` or `DRG`), and by
67
+ all of its names otherwise. The operator metadata lists both operators in the category of
68
+ `drg`, with the preferred names `MS-DRG` and `CMS-DRG`.
69
+
70
+ ### Changed
71
+
72
+ - **Breaking:** codes match case-exactly. A code selects a concept only when it has the case
73
+ of the concept's `concept_code`, so `["icd10cm", "I16.5"]` selects I16.5 as before and
74
+ `["icd10cm", "i16.5"]` selects no rows. A code in the wrong case also gets the warning
75
+ `improperly formatted code`, because the format check no longer ignores case. Before, the
76
+ lookup compared `lower(concept_code)` with the code in lower case, and the format check
77
+ compiled each vocabulary_formats pattern with an ignore-case flag. A vocabulary can still
78
+ ignore case: its vocabulary_formats row declares it with the scoped flag `(?i:...)`. Then
79
+ the format check honors the flag, and the lookup compares `lower(concept_code)`. No row
80
+ declares it today. The unknown-code warning compared case exactly before and still does.
81
+ A survey of the 1,259 production algorithms (2026-10-31) found no code that matches only
82
+ when case is ignored. Its 150 lower-case codes, all SEER staging codes such as `cX` and
83
+ `M1a`, match their concept codes exactly. The `snf` operator looked up its file provenance
84
+ with the code `SNF`, which found the concept `snf` only while the lookup ignored case. It now
85
+ uses `snf`, so it selects the same records as before.
86
+ - **Breaking:** the pinned vocabulary_formats table (`config/vocabulary_formats.tsv`) is
87
+ vocabulary_formats commit `dbd670ae17be0afa3002ce40e5e327c509e62f9c`, which makes every row
88
+ case-exact. The default rows of ICD10CM, ICD10, ICD10PCS, HCPCS, CPT4, CPT4_HCPCS, the
89
+ `*_MODIFIER` vocabularies, ATC, ICDO3, ICD03_Topography and EDI no longer accept lower-case
90
+ letters, and the rows that used `\w` accept only digits and upper-case letters in its place.
91
+ - Provenance keywords and the codes of `place_of_service_filter` match case-exactly too,
92
+ because their vocabularies have no vocabulary_formats row. `"Inpatient"` is the validation
93
+ error `unrecognized keywords`, as in 0.5.0, and the plain query raises
94
+ `ConceptQL::InvalidStatementError` for it. Every provenance concept code is in lower case.
95
+ The unreleased change that made the keyword validation ignore case is withdrawn.
96
+ - **Breaking:** `episode` merges a record that starts inside an episode and ends after it. A
97
+ record now stays in the episode when its start_date is no more than `gap_of` days after the
98
+ latest end_date of the episode's earlier records. A NULL end_date counts as the start_date.
99
+ Before, the operator compared that end plus `gap_of` with the latest end_date including the
100
+ record itself, so a record that ended more than `gap_of` days after the episode began a new
101
+ episode, even when it started inside the episode. For example, the records 2020-01-01 to
102
+ 2020-01-10 and 2020-01-05 to 2020-03-01 gave two overlapping episodes with `gap_of` 0 or
103
+ 30, and now give one episode from 2020-01-01 to 2020-03-01. The result changes only for
104
+ records that last more than one day. Records of one day give the same episodes as before,
105
+ so the test statement `episode/results_icd9_episodes_w_30d_gap.json` returns the same rows.
106
+ Statements that make episodes of records with durations, such as drug exposures or
107
+ hospital stays, return fewer and longer episodes.
108
+ - **Breaking:** `provenance` with no keyword fails validation with
109
+ `["no provenance keywords; use \"*\" for any provenance"]`, and its plain query raises
110
+ `ConceptQL::InvalidStatementError`. The error does not need a database. Before,
111
+ `["provenance", <stream>]` kept every record with any provenance. Write
112
+ `["provenance", <stream>, "*"]` for that. No production algorithm used the empty form on
113
+ 2026-10-31.
114
+ - **Breaking:** a Database on a real database without vocabulary raises
115
+ `ConceptQL::Lexicon::NoVocabularyError`. `ConceptQL::Database.new(db)` raises it when `db`
116
+ is a real database (not nil and not a Sequel mock) and neither `db` nor the `LEXICON_URL`
117
+ database has all the OHDSI vocabulary tables (`concept`, `concept_ancestor`,
118
+ `concept_relationship`, `vocabulary`) or all the GDM ones (`ancestors`, `concepts`,
119
+ `mappings`, `vocabularies`). Before, such a Database silently used no vocabulary: every code
120
+ matched no rows and the unknown-code warnings turned off, so `["icd9", "412"]` returned 0
121
+ rows instead of 48. Code lists are not exempt. The message names the data database (never
122
+ its user or password), its search path on Postgres and DuckDB, the missing tables of each
123
+ kind in each database searched, whether `LEXICON_URL` is set, and the fixes. `#searches`
124
+ holds the same facts. A Database with no db or a Sequel mock (diagram and SQL rendering)
125
+ still uses no vocabulary, and a Database given `lexicon:` (for example
126
+ `ConceptQL::Lexicon.for_dataset(db)`) is unaffected. `Lexicon::MissingVocabularyError` is a
127
+ subclass of `NoVocabularyError`, so one `rescue` covers both.
128
+ - **Breaking:** the plain query raises `ConceptQL::InvalidStatementError` for a statement with
129
+ validation errors. `Query#query`, `#sql` and `#sql_statements`, and `#all`, `#count`,
130
+ `#execute`, `#order` and `#analyze` through `#query`, now validate the statement first,
131
+ as `#code_list` already did. The error's message lists each error that `annotate` shows,
132
+ with the name of its operator. `#errors` holds the same errors as the `errors` hash of
133
+ `scope_annotate`, and `#statement` holds the statement. Warnings do not raise. Examples of
134
+ warnings are the `count` and `drg` deprecations, an unknown code and an improperly formatted
135
+ code. Before, an invalid statement could crash while it built (`provider_filter` without
136
+ `roles` raised NoMethodError, and an unknown operator raised RuntimeError
137
+ `Invalid#query called`). It could also build SQL that silently returned fewer rows.
138
+ `provenance` ignored an unrecognized keyword, such as a concept ID or
139
+ `inpatient:primary_or_first`, and `count` ignored every upstream after the first. Those
140
+ statements now raise. An invalid window table and an empty statement also raise it (see
141
+ Fixed). The error is a `Query::QueryError`, and its old name
142
+ `ConceptQL::Query::InvalidStatementError` still works. `annotate` does not raise.
143
+ - **Breaking:** `numeric` without an upstream fails validation when its column name is not a
144
+ column of the person table. That form (`["numeric", "<name>"]`) reads the column from the
145
+ person table (`patients` in gdm and gdm_wide). A name that the table lacks is now the
146
+ validation error `["unknown column", "<name>"]`, and `annotate` runs no counting query for
147
+ the operator. This includes a column that only stream rows have, such as `criterion_id`. In
148
+ 0.5.0 on DuckDB, `["numeric", "criterion_id"]` ran and returned 249 rows on the test data.
149
+ Now it fails validation, and its plain query raises `ConceptQL::InvalidStatementError`. A
150
+ name that is neither a column of the person table nor an output column, such as
151
+ `no_such_column`, also failed in 0.5.0, but only at run time. The match ignores case, as
152
+ DuckDB and Spark do.
153
+ - **Breaking:** `filter`, `except` and `person_filter` with an `as` option fail validation with
154
+ `["unsupported option", "as", <value>]`. None of them has an `as` option. In 0.5.0, the
155
+ statement passed validation and ran as a plain filter, except or person_filter, so a user
156
+ who meant to cast both streams got other rows with no warning. Now the plain query of such a
157
+ statement raises `ConceptQL::InvalidStatementError`. A blank `as` counts as not set. Other
158
+ options that these operators read but do not declare (`ignore_dates` for filter and except,
159
+ and the t_shank-only options) still validate.
160
+ - **Breaking:** `union` removes exact duplicate rows. A row that arrives from more than one
161
+ upstream comes out once, because every stream is a set. Before, `union` joined its
162
+ non-vocabulary upstreams with `UNION ALL`, so
163
+ `["union", ["first", ["icd9", "412"]], ["icd9", "412"]]` returned each first MI twice. Two
164
+ rows are duplicates only when every column is equal, `label` included. The duplicate
165
+ removal is expensive on large data, so `union` keeps `UNION ALL` when no row can come from
166
+ two of its upstreams: when the upstreams are vocabulary operators, `cpt_or_hcpcs`-style
167
+ operators and unions of them, and each pair has different domains or different
168
+ vocabularies. Statements that union a labeled operator with a `recall` of it return fewer
169
+ rows.
170
+ - **Breaking:** `trim_date_end` and `trim_date_start` with `compare_all` return one row for
171
+ each left row. With `compare_all`, each left row is compared with every right row of the
172
+ person (and of the `join_columns`). `trim_date_end` now trims the end_date to the earliest
173
+ right start_date on or after the left start_date. `trim_date_start` trims the start_date to
174
+ the latest right end_date on or before the left end_date (each date as `within` moves it,
175
+ when `within` is given). Before, the left row came out once for each such
176
+ right row: trimmed by each one, and also untrimmed when a right row started after the left
177
+ row ended. For example,
178
+ `["trim_date_end", {"left": ["icd9", "412"], "right": ["cpt", "99214"], "compare_all": true}]`
179
+ returned event 523 twice, with the end dates 2008-03-17 and 2008-03-21, and now returns it
180
+ once, with 2008-03-17. With `["icd9", "412"]` as both the left and the right, `trim_date_end`
181
+ returned 60 rows for 48 left events, and now returns 48. Which left rows come out does not
182
+ change: a left row is dropped only when it has right rows and none of them passes, and a
183
+ left row without right rows passes through unchanged. t_shank sets `compare_all` for its
184
+ baseline windows. Statements that count the rows of such a trim count fewer rows.
185
+ - **Breaking:** `trim_date_end` and `trim_date_start` with `join_columns` and without
186
+ `compare_all` trim each left row by the first (`trim_date_end`) or last (`trim_date_start`)
187
+ right row of its person and its `join_columns` values. That is the grouping that
188
+ `compare_all` uses. Before, the operator took the person's first or last right row and only
189
+ then joined on the `join_columns`, so a left row whose join values differed from that row's
190
+ found no right row and passed through untrimmed. For example, `trim_date_end` with
191
+ `["icd9", "412", {"uuid": true}]` as the left, the same events 30 days earlier as the right,
192
+ and `"join_columns": ["uuid"]` returned 11 of the 48 events, one for each event that was
193
+ not the first of its person, and now returns none. t_shank sets `compare_all` whenever it
194
+ gives `join_columns`, so its exports do not change.
195
+ - **Breaking:** the `uuid` column (the option `uuid`) gives a NULL part as an empty string.
196
+ The uuid joins `person_id`, `criterion_id`, `criterion_table`, `start_date` and `end_date`
197
+ with `/`. Before, one NULL part, such as an event without an `end_date`, made the whole uuid
198
+ NULL. A NULL uuid matches nothing, so `trim_date_end` or `trim_date_start` with
199
+ `"join_columns": ["uuid"]` passed such a window through untrimmed. Now an event without an
200
+ `end_date` has a uuid such as `1/2/observations/2008-03-10/`. A uuid without a NULL part
201
+ does not change.
202
+ - **Breaking:** a date adjustment that is a bare number now acts as the same number of days
203
+ with the unit `d`. With an `r`, `e` or `s` prefix, `R365` means 365 days back from the origin, `E30` means `end_date` +
204
+ 30 days, and `S7` means `start_date` + 7 days, the same as `R365d`, `E30d` and `S7d`. In
205
+ 0.5.0, these strings gave no adjustment and no error. t_shank puts `R` before lookbacks and
206
+ blackout periods, so a lookback of `R365` was a lookback of zero days. ConceptQL now also
207
+ reverses a bare number where it reverses an adjustment with a unit, such as `365d`. Those
208
+ places are the `within` of a temporal operator, the `at_least` of `before`, and the `start`
209
+ of `concurrent_within`. In 0.5.0, these options added a bare number to the date, and now
210
+ they count it back from the date. So a statement with such a value selects a different date
211
+ range than in 0.5.0. `DateAdjuster#adjustments` also no longer returns the result of an
212
+ earlier call that had a different `reverse` value.
213
+ - The code formats come from vocabulary_formats. conceptql no longer keeps its own format
214
+ patterns. A code that does not match its vocabulary's format is still the warning
215
+ `improperly formatted code`, which names the code, as in 0.5.0. The statement stays valid:
216
+ `annotate` counts the operator, the plain query and `Query#code_list` run, and the
217
+ malformed code matches no rows. A code is never a prefix (`["icd9", "410*"]` does not
218
+ select `410.00`), and a bare `"*"` still selects every code. A `cpt_or_hcpcs`-style operator
219
+ gives the warning only when every one of its vocabularies rejects the code's format.
220
+ `config/vocabulary_formats.tsv` is a copy of the published table of the public repository
221
+ `outcomesinsights/vocabulary_formats`, and `config/vocabulary_formats.sha` records its
222
+ commit. `just vocabulary-formats <ref>` refreshes both. Each vocabulary uses its default row
223
+ (the row whose `vocabulary_id` is the vocabulary's id), anchored as `\A(?:pattern)\z`. A
224
+ vocabulary without a row gets no format check, and an unknown code in it is still the warning
225
+ `unknown code(s)`. Compared with 0.5.0: LOINC and Gemscript are no longer checked, so
226
+ standard LOINC codes such as `20-8` and `1-8` no longer get the format warning. ICD10PCS
227
+ accepts the hierarchy codes of 1 to 7 characters (`0016`). NDC accepts only the 11-digit
228
+ claims form, so the 9-digit product codes (`003630641`) and the alphanumeric codes
229
+ (`00019N068`) now get the format warning. RxNorm Extension, ICDO3 and EDI, which come from
230
+ the lexicon, are now checked. The other patterns are unchanged. `config/vocabularies.csv`
231
+ has no `format_regexp` column, and a `format_regexp` column in a custom vocabularies file is
232
+ ignored. The `format_regexp` of a vocabulary entry's metadata is the anchored pattern from
233
+ vocabulary_formats.
234
+ - The format warning of an NDC code says why the code matches nothing:
235
+ `improperly formatted code (claims use 11-digit package NDCs, so it matches no rows)`. The
236
+ claims carry the 11-digit package NDC, so a code in another form selects no rows. The
237
+ warning keeps its shape, the message and then the codes, and the message still starts with
238
+ `improperly formatted code`. The other vocabularies keep the plain text.
239
+ - `occurrence` with `at_least` returns each row once, because every stream is a set. Each
240
+ earlier row of a person picks its nth row, and with `at_least` several earlier rows can pick
241
+ the same row. Before, that row came out once for each earlier row that picked it, so
242
+ `["occurrence", 2, ["icd9", ...], {"at_least": "30d"}]` returned 2366 rows, of which only
243
+ 1163 were distinct. The same rows come out, and only the copies are gone. Statements that
244
+ count the rows of such an `occurrence` count fewer rows.
245
+ - `cpt_or_hcpcs` joins its vocabulary branches (CPT4, HCPCS and CPT4_HCPCS) with `UNION ALL`
246
+ instead of `UNION`. Each branch selects the rows of a different vocabulary, so no row can
247
+ come from two branches. The duplicate removal was the most expensive step of a
248
+ `cpt_or_hcpcs "*"` query, and it ran out of memory on full SEER-Medicare data. A row that
249
+ one branch repeats (for example, through a window table with repeated rows) now stays,
250
+ as it does for `cpt` and `hcpcs`. A custom multiple-vocabularies file that lists one
251
+ vocabulary twice keeps `UNION` for that operator.
252
+ - `Query#annotate` and `Query#scope_annotate` with counts on now count every operator with
253
+ one SQL statement on DuckDB and Postgres. The statement holds the root query's
254
+ `MATERIALIZED` CTEs and a `UNION ALL` of one count for each operator's CTE. An operator
255
+ that the root query does not evaluate (a vocabulary operator that `union` combines with
256
+ others) adds its own CTE to the statement. Before, each operator ran its own counting
257
+ query, which evaluated all of its upstream operators again. The counts and the output
258
+ shape do not change, and an operator with no rows still reports zeros. Spark does not
259
+ honor `MATERIALIZED`, so it keeps one counting query for each operator. The old path is
260
+ also used when the statement has an operator that annotate does not count (an invalid
261
+ operator, for example), when temp tables replace the CTEs (`force_temp_tables`), and when
262
+ `CONCEPTQL_AVOID_CTES=true` turns the CTEs off. It is also used when the Database has the
263
+ `share_utilization` option and the statement has a `hospitalization`, `snf` or `hospice`
264
+ operator, because the shared tables are not CTEs. `skip_counts: true` runs no counting
265
+ query, as before.
266
+ - `annotate` with one counting query for each operator (Spark, temp tables and the other
267
+ cases above) builds its SQL faster. The data model found the vocabulary column of each
268
+ table again each time it built an operator's columns, and now finds it once. The SQL and the
269
+ counts do not change. On a 111-operator production algorithm on DuckDB, that `annotate`
270
+ fell from 36 s to 30 s.
271
+ - The gemspec accepts sequelizer 0.5 (`>= 0.3.0, < 0.6`; before, `< 0.5`), so an
272
+ application can use sequelizer 0.5's `dataset_db` and `with_dataset` with conceptql.
273
+ conceptql calls neither of them, and the parts of sequelizer that conceptql uses did not
274
+ change in 0.5. In sequelizer 0.5, an unqualified table name reads a dataset only inside
275
+ `with_dataset`, so call `ConceptQL::Lexicon.for_dataset` and run the queries inside that
276
+ block. conceptql's own Gemfile stays on the sequelizer 0.4 release.
277
+ - The SQL of a statement is smaller and takes less time to plan. The rows do not change.
278
+ - CTEs with the same SQL and the same WITH options merge into one. A statement evaluates
279
+ some operators more than once (a `recall`, for example), and each evaluation used to add
280
+ its own copy.
281
+ - An IN or NOT IN list of at least `ConceptQL::Scope::CONCEPT_LIST_MIN_ITEMS` (1,000)
282
+ integers, such as a list of provenance concept IDs, goes to the database once. It becomes
283
+ a `VALUES` CTE that each copy of the list reads. The CTE is `MATERIALIZED` on the
284
+ databases that support it, which excludes Spark. A shorter list stays an IN list. On
285
+ Spark, a list in a SELECT-list expression (the CASE flags of `one_in_two_out`) stays an
286
+ IN list, because Spark accepts an IN subquery only in a filter or a join. On Spark, a
287
+ NOT IN list also stays a NOT IN list, because Spark rejects a NOT IN subquery in a nested
288
+ condition, such as the `IS NULL OR NOT IN` of `gender`.
289
+ - On a 111-operator production algorithm, DuckDB planning of the plain query fell from
290
+ 0.81 s to 0.57 s and `annotate` with counts from 2.7 s to 2.0 s. Postgres planning of the
291
+ same statement on the test data fell from 480 ms to 152 ms.
292
+ - The specification (`doc/spec.md`) describes the changed `union` and `filter`, the new
293
+ `ms_drg` and `cms_drg`, the `count` and `drg` deprecations, and `sum` with
294
+ `group_by_person`. It gives the full format of a date adjustment, with the unit `w` and the
295
+ prefixes `r`, `e` and `s`. New sections describe `trim_date_end` and `trim_date_start`
296
+ (with `compare_all`) and the `window` statement with a window table. It says that ConceptQL
297
+ raises an error when it finds no vocabulary for a real database.
298
+
299
+ ### Deprecated
300
+
301
+ - The `count` operator is deprecated. Every stream is a set, so `count` returns 1 for nearly
302
+ every row. A statement that uses `count` still runs and returns the same rows. Annotation
303
+ adds the warning `deprecated operator` on `count`, and a later release will make it an
304
+ error. To count the events of each person, use
305
+ `["sum", ["numeric", 1, <stream>], {"group_by_person": true}]`. Without `group_by_person`,
306
+ `sum` splits the count of one person over several rows when the events have different
307
+ source values. `ConceptQL::Operators::Count.to_metadata` says `deprecated: true`.
308
+ `ConceptQL.metadata` does not list `count`, because it lists only the operators that have a
309
+ category, and `count` has none. The new class-level declaration `deprecated` marks an
310
+ operator.
311
+ - The `drg` operator is deprecated. A DRG code can have two meanings, and `drg` selects the
312
+ events of both. A statement that uses `drg` still runs and returns the same rows.
313
+ Annotation adds the warning `deprecated operator` on `drg`, and a later release will make
314
+ it an error. Use `ms_drg` for the MS-DRG meaning (events from 2007-10-01) or `cms_drg` for
315
+ the CMS-DRG meaning (events before 2007-10-01). The operator metadata of `drg` says
316
+ `deprecated: true`.
317
+
318
+ ### Removed
319
+
320
+ - **Breaking:** the `concurrent_within` operator is gone. A statement that names it gets the
321
+ validation error `invalid operator`, as any unknown operator does, and its plain query
322
+ raises `ConceptQL::InvalidStatementError`. No production algorithm used it on 2026-10-31:
323
+ none of the 1,652 algorithm rows (current, old and deleted) named it. Its test statements
324
+ and expected results are removed with it. The SQL of every other operator does not change.
325
+ - The unused methods `query_cols` of the `race` and `ethnicity` operators and the unused class
326
+ method `Operator.query_columns` are removed. Nothing called them, and the two `query_cols`
327
+ methods would have raised `NoMethodError`. The `race` and `ethnicity` operators are not
328
+ affected: their SQL and their results do not change. `Query#query_cols` is a different
329
+ method and stays.
330
+ - The hidden vocabulary operators `place_of_service` (alias `place of service`) and
331
+ `specialty` are removed, with their rows in `config/vocabularies.csv`. Neither one worked.
332
+ Their rows named the vocabularies `Place of Service` and `Specialty`, but the OHDSI and the
333
+ GDM lexicons name them `CMS Place of Service` and `Medicare Specialty`. So
334
+ `place_of_service` selected no record and gave no warning. `specialty` read the column
335
+ `clinical_code_vocabulary_id` from `practitioners`, which has no such column, so its query
336
+ failed with a database error. No production algorithm used either name. Now each name is
337
+ the validation error `invalid operator`. The lexicon's own operators for these
338
+ vocabularies, `cms_place_of_service` (also written `cms place of service`) and
339
+ `medicare_specialty`, do not change. To keep records by their place of service or their
340
+ provider's specialty, use `place_of_service_filter` or `provider_filter`. The SQL of every
341
+ other statement is unchanged.
342
+
343
+ ### Fixed
344
+
345
+ - `place_of_service_filter` selects records again. It looked up its codes in the vocabulary
346
+ `Place of Service`, but the OHDSI and the GDM lexicons name that vocabulary
347
+ `CMS Place of Service`. So no code became a concept ID, and the operator kept no record on
348
+ any data. Now `["place_of_service_filter", <stream>, "21"]` keeps the records whose place of
349
+ service (`pos_concept_id` of the record's context in gdm, of the observation in gdm_wide)
350
+ is the concept of code 21, Inpatient Hospital. A record with no place of service is still
351
+ dropped. A code that is not in the vocabulary is now the validation error
352
+ `unknown code(s)`, with the code as written, and the plain query raises
353
+ `ConceptQL::InvalidStatementError` for it. A code can never match a record when it names no
354
+ concept, so this is an error and not a warning. The codes have two digits, so the number
355
+ `1` is an unknown code and `"01"` is Pharmacy. Without a lexicon, no code is checked. The
356
+ argument metadata names the vocabulary `CMS Place of Service`. The SQL of the operator now
357
+ has the concept IDs of its codes. The SQL of every other statement is unchanged.
358
+ - `CONCEPTQL_AVOID_CTES=true` works again. Since 2022, each operator read its rows from a CTE
359
+ of its own, but in this mode the statement had no WITH list. So the database refused the
360
+ SQL of every statement because the CTE did not exist. Now each operator is a
361
+ subquery in this mode, and a long concept list stays an IN list. The operators `occurrence`
362
+ (with `at_least` or `within`) and `co_reported` still use CTEs of their own, inside their
363
+ subquery. With the Database's `share_utilization` option, the statement reads the shared
364
+ table as in the default mode. The SQL of the default mode is unchanged.
365
+ - An empty statement (`[]`) is now an invalid statement. `validation_errors` and
366
+ `scope_annotate` report the error `{"invalid" => [["empty statement"]]}`, and `query`, `sql`
367
+ and the other methods that build SQL raise `ConceptQL::InvalidStatementError`. This also
368
+ applies to an empty operand (`["union", [], ["icd9", "412"]]`), to `[[]]`, to a `window`
369
+ around `[]`, and to an `algorithm` whose stored statement is `[]`. Before, each of these
370
+ raised `ArgumentError` in `Nodifier#create`.
371
+ - The statement `[nil]` or `[""]` reports its error under the key `invalid`:
372
+ `{"invalid" => [["invalid operator", ""]]}`. Before, the key was `""`.
373
+ - `Operators::Invalid#operator_name` gives the operator name of the statement (`nope_op` for
374
+ `["nope_op", "412"]`), and `invalid` when the statement has no name. Before, it removed the
375
+ first code from the arguments and gave that code (`412`).
376
+ - A `ConceptQL::Database` on a Sequel mock (`Sequel.mock`) now reads its vocabulary from the
377
+ `LEXICON_URL` database. A mock answers yes to every table check, so the Lexicon chose the
378
+ mock as an OHDSI vocabulary that has no rows, and every vocabulary from the lexicon was an
379
+ invalid operator. Now the Lexicon never searches a mock data database for vocabulary tables.
380
+ Without `LEXICON_URL`, a mock gets `LexiconNoDB`, and `ConceptQL::Database.new` raises no
381
+ `NoVocabularyError` for it.
382
+ - `annotate` reports the errors of a window table that is an invalid ConceptQL statement.
383
+ `scope_annotate` reports them under the key `window_table`, and each message starts with
384
+ the name of its operator (for example
385
+ `["provider_filter: required option not present", "roles"]`). `annotate` then counts
386
+ nothing, because every count joins the window table. The plain query of the outer
387
+ statement raises `InvalidStatementError` with these errors and the outer statement. In
388
+ 0.5.0, `annotate` did not validate the window table, so it reported no error for it, and a
389
+ counted `annotate` built the window table's SQL without a check.
390
+ - `inner_join` and `include_rhs_columns` no longer return a row more than once. With either
391
+ option, a temporal operator (for example `during`, `contains` or `any_overlap`) joins each
392
+ left row to every matching right row, and it gave one copy of the left row for each match.
393
+ On the DuckDB test data, `during` of `icd9 412` in `information_periods` with
394
+ `include_rhs_columns: ["criterion_domain"]` gave 176 rows for 48 records. Each row now comes
395
+ out once. With `include_rhs_columns`, rows that differ in a right-side column are different
396
+ rows, so a left row still comes out once for each different value, such as each right
397
+ `uuid`.
398
+ - Building a statement's SQL no longer runs a query to learn a dataset's columns. Before,
399
+ `occurrence` with `group_by_date` over a labeled upstream failed on every database with
400
+ "Table with name <label> does not exist" (Postgres: "relation ... does not exist"): it asked
401
+ the database for the columns of the labeled operator's CTE, and that CTE does not exist
402
+ while the SQL is built. A `recall` of the label later in the statement did not help. The
403
+ same kind of query ran in three other places. `include_rhs_columns` on a temporal operator
404
+ (for example `contains`) failed the same way when its right side was labeled. A
405
+ `window_table` scope window and the provider columns of an operator that reads the person
406
+ table ran the query on every build. Each of these now names its columns, so no query runs.
407
+ The test suite now fails when building any test statement's SQL asks the database for
408
+ columns.
409
+ - `numeric` with an upstream and a column name (`["numeric", "quantity", stream]`) now
410
+ fails validation when the upstream's rows lack that column. The error is
411
+ `["unknown column", "<name>"]`, so `annotate` reports it and runs no counting query.
412
+ Before, the statement passed validation and failed at run time with a "column not found"
413
+ error. The column is read from the rows that the upstream emits, not from the tables that
414
+ the upstream reads. Every stream emits the same columns, so an upstream that mixes tables
415
+ (a `union` of two domains, for example) gets the same check. The match ignores case, as
416
+ DuckDB and Spark do. `numeric` without an upstream gets the same check against the person
417
+ table (see Changed).
418
+ - `numeric` with a column name in another case than the column's own
419
+ (`["numeric", "Value_As_Number", stream]`) now runs on Postgres. Before, the SQL quoted the
420
+ name as written, and Postgres found no such column. The SQL now uses the column's own name,
421
+ so validation and the SQL agree on every database.
422
+ - `Query#annotate` now gives each operator's counts (`annotation[:counts]`) in domain-name
423
+ order, with both counting paths (one SQL statement, or a query for each operator). Before,
424
+ the order came from the database, and the zero counts of domains with no rows came last.
425
+ On DuckDB, the order of a multi-domain operator's domains changed from run to run, so a
426
+ diagram drawn from the counts could change between identical runs. The keys and the
427
+ numbers are the same as before.
428
+ - `filter` now matches a left record only to a right record with the same
429
+ `criterion_table`, as well as the same person, `criterion_id` and `criterion_domain`. A
430
+ `criterion_id` is an id within its table. Before, a record matched a record from another
431
+ table of the same domain whenever their ids were equal. For example,
432
+ `hospitalization` records (table `collections`, domain `condition_occurrence`) matched
433
+ `icd9` records from `observations`: `filter(icd9 "*" by hospitalization, ignore_dates)`
434
+ kept 8 such records on the test data, and now keeps none.
435
+ - `DataModel::Base#table_columns` no longer prints its result and its table names to
436
+ stdout. The two `p` calls were debug output from 2019. No statement in the test suite
437
+ calls this method, so the SQL and the results are unchanged.
438
+ - `ConceptQL.metadata` serves the `desc` of each operator again. Since 2024-12, every
439
+ operator served the vocabulary text, for example "Selects records based on the After
440
+ vocabulary." for `after`. The diagram editor showed this text to users. Now an operator
441
+ serves the text that it sets with `desc`, and an operator that sets `no_desc` serves an
442
+ empty text. A vocabulary operator, which sets no `desc`, still serves "Selects records
443
+ based on the <name> vocabulary." The SQL is unchanged.
444
+ - In `ConceptQL.metadata`, each vocabulary operator serves `predominant_domains` as a flat
445
+ list, for example `["condition_occurrence"]`, as the other operators do. Before, it served
446
+ the list inside another list (`[["condition_occurrence"]]`). The SQL is unchanged.
447
+ - The diagram editor shows the `desc` of each operator to its users, so 14 texts are
448
+ rewritten to say what the operator does. `dedup` served the text of `count`. `numeric` read
449
+ like a code comment. `provenance` now says that it needs at least one keyword, that `*`
450
+ matches any provenance, and that `<file keyword>:<code keyword>` matches the file AND the
451
+ code. `information_periods` claimed that it takes an upstream stream, which it refuses.
452
+ `concurrent_within` said that the date ranges overlap, but the other record must lie
453
+ inside the record's date range. `provider_filter` now names its required `roles` option.
454
+ `death`, `person` and `sum` now describe all of their inputs, and `occurrence`, `recall`,
455
+ `hospice`, `hospitalization` and `snf` were fixed for typos or a missing final period.
456
+ Every served `desc` now ends with a period. Also, every operator serves
457
+ `predominant_domains` as strings. Before, a vocabulary without a domain and an operator that
458
+ declares its domains, such as `death`, served symbols (`:condition_occurrence`, `:death`)
459
+ to Ruby callers. The JSON and the SQL are unchanged.
460
+
461
+ ## [0.5.0] - 2026-10-29
462
+
463
+ ### Removed
464
+
465
+ - **Breaking:** the `omopv4_plus` data model (OMOP CDM v4) is gone. ConceptQL now supports
466
+ only `gdm` and `gdm_wide`. A `ConceptQL::Database` created with `data_model: :omopv4_plus`
467
+ raises `ConceptQL::DataModel::UnsupportedError`, which says that the model was removed.
468
+ The OMOP query modifiers, `schemas/omopv4_plus.yml`, the OMOP part of the `read` operator,
469
+ the OMOP and cost vocabulary behaviors, the `omopv4_vocabulary_id` column of
470
+ `config/vocabularies.csv` and the rake task `test_omopv4_plus` are removed with it. The
471
+ OHDSI vocabularies (`ohdsi_vocabs`) are not affected. The SQL for `gdm` and `gdm_wide` does
472
+ not change.
473
+
474
+ ### Added
475
+
476
+ - The gem ships the specification, `doc/spec.md`. `ConceptQL.spec_path` returns its path, so
477
+ applications can serve it without building a path into the gem directory. The Jigsaw
478
+ diagram editor serves it to agents over MCP.
479
+ - Annotate warns about a selected code that has more than one meaning in the vocabulary, with
480
+ the warning "code(s) with more than one meaning". Each item names the code and all of its
481
+ distinct names, for example DRG 410, which meant chemotherapy until 2007-09-30 and a biliary
482
+ tract procedure after that. `Lexicon#code_meanings` is the new cached lookup behind it.
483
+
484
+ ### Changed
485
+
486
+ - The `co_reported` description now says that a source record is kept only if every upstream
487
+ stream has an event in it, and that the events of all streams in any other record are
488
+ dropped without a warning. It no longer mentions OMOP visits. The spec has a new `co_reported` section that says the same. The
489
+ operator's behavior does not change. Applications that show operator descriptions (the
490
+ Jigsaw diagram editor) show the new text.
491
+ - The specification (`doc/spec.md` and its source `doc/spec.md.cql`) now describes GDM
492
+ instead of OMOP CDM v4. Every example statement now validates on `gdm_wide`. Each example
493
+ table comes from the DuckDB test fixture, with the columns `criterion_table` and
494
+ `criterion_domain` instead of `criterion_type`. The diagrams are drawn again from the same
495
+ data. Examples that used removed operators (`intersect`, `complement`, `primary_diagnosis`,
496
+ `place_of_service_code`, `visit_occurrence`, `procedure_cost`, `payer`, `value`,
497
+ `greater_than`) now use `filter`, `provenance`, `hospitalization`, `co_reported` and
498
+ `numeric_filter`, or are removed. The removed sections are "Intersect", "Complement",
499
+ "Casting to a visit_occurrence", "Cast all the Things!", the intersect question in
500
+ "Discussion about Set Operators", and "How to Handle fact_relationship Table from CDMv5".
501
+ The new sections are "Filter" and "Casting to death". The Appendix C proposal "Filter
502
+ Operator" is now "`as` Option for Filter", because the filter operator exists. "Time Windows" is now a `##` section,
503
+ so that no section is larger than about 23 KB.
504
+
7
505
  ## [0.4.1] - 2026-10-28
8
506
 
9
507
  ### Changed