conceptql 0.7.0 → 0.8.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +4 -0
  4. data/config/vocabularies.csv +1 -0
  5. data/docs/spec/README.md +5 -3
  6. data/docs/spec/examples/recall-operator.json +6 -3
  7. data/lib/conceptql/annotation.rb +136 -0
  8. data/lib/conceptql/behaviors/provenanceable.rb +44 -26
  9. data/lib/conceptql/cli.rb +0 -6
  10. data/lib/conceptql/database.rb +74 -4
  11. data/lib/conceptql/diagram.rb +4 -1
  12. data/lib/conceptql/diagram_renderer/geometry.rb +6 -1
  13. data/lib/conceptql/lexicon/lexicon_strategy.rb +23 -12
  14. data/lib/conceptql/lexicon.rb +24 -35
  15. data/lib/conceptql/nodifier.rb +16 -0
  16. data/lib/conceptql/operators/co_reported.rb +1 -0
  17. data/lib/conceptql/operators/information_periods.rb +7 -3
  18. data/lib/conceptql/operators/multiple_vocabularies.rb +42 -22
  19. data/lib/conceptql/operators/operator.rb +38 -8
  20. data/lib/conceptql/operators/place_of_service_filter.rb +24 -12
  21. data/lib/conceptql/operators/provenance.rb +11 -11
  22. data/lib/conceptql/operators/recall.rb +5 -0
  23. data/lib/conceptql/operators/union.rb +13 -2
  24. data/lib/conceptql/operators/vocabulary.rb +73 -24
  25. data/lib/conceptql/query.rb +111 -37
  26. data/lib/conceptql/scope.rb +1 -18
  27. data/lib/conceptql/version.rb +1 -1
  28. data/lib/conceptql/vocabularies/drg_systems.rb +3 -3
  29. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +15 -10
  30. data/lib/conceptql/vocabularies/entry.rb +2 -2
  31. data/lib/conceptql/vocabularies/read_codes.rb +42 -0
  32. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +3 -0
  33. metadata +3 -3
  34. data/lib/conceptql/lexicon/lexicon_gdm.rb +0 -50
  35. data/lib/conceptql/operators/read.rb +0 -79
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf5cfc7db1200e2f00e6006a54f138e6837c1d05483330c7dccb5f9b8a93a907
4
- data.tar.gz: 8095c96f350258266df92fcd521d99460a9ff48af70657b6b980294a29fa6a47
3
+ metadata.gz: 296389d035ad781649b32662a3fafba66aa209dea6c76fe039b372463854293f
4
+ data.tar.gz: 9e7225d0b68c8dd72a7b6b6ade8f3ffdb67b3b1c8cd6507e53e8d84a1fa0a6bc
5
5
  SHA512:
6
- metadata.gz: 3a04d687527d5d808446f3fef305805a6915679c1c079dc1edf98afdb9fa7c650525e929b89ee227fe0e9066d58c81585e8c027ff18efc5a793c30bece1454cd
7
- data.tar.gz: c4302550620621d1dc241c60a0a47fe4fe31b9ee0dc143d4bd467fb52a775aff5f46eeac815a1470ef661c0103e716e518610f04d55f8ec4b4975a6c4ba9d3f4
6
+ metadata.gz: fb10f21a65e9d4f3dfebd0fa559d1e8c5465d8e69ecf281d62494c981d51c5c70054e8e1b04782d0a15c1ebda762aef1b36e477d1a667cce266de5d8ce053a06
7
+ data.tar.gz: ceb2fd1d113b3fc37af25bb613e1405ad250a7d6452c2e710020df2fc9341c4398524add0f1b0344834e74348b6f78145a91b43e73f6347edeb6c01f159e3267
data/CHANGELOG.md CHANGED
@@ -4,6 +4,161 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.0] - 2026-11-10
8
+
9
+ ### Added
10
+
11
+ - `ConceptQL::Annotation` reads a statement that `Query#annotate` annotated, or the same
12
+ statement parsed back from JSON. `#each_operator` gives each operator's name, effective id
13
+ and annotation hash. `#errors` and `#warnings` give every problem as
14
+ `{ operator:, id:, message:, details: }`, and `#valid?` is true when no operator has an
15
+ error. Warnings never make a statement invalid. It walks the positional upstreams and the
16
+ `left`, `right` and `window_table` options. `Query#errors`, `Query#warnings` and
17
+ `Query#valid?` use it on `annotate(skip_counts: true)`, so they run no count SQL. See
18
+ `docs/annotation.md`.
19
+ - The `reference_lexicon:` option of `ConceptQL::Database` gives a Database the full lexicon
20
+ beside the lexicon of its dataset (`Database#reference_lexicon`). When the option is not
21
+ given and `LEXICON_URL` is set, the `LEXICON_URL` lexicon is the reference lexicon, with
22
+ its own cache. Only the code checks of the vocabulary operators, `information_periods`,
23
+ `provenance` and `place_of_service_filter` read it. A code that the
24
+ dataset's lexicon does not know gets the new warning "code(s) not present in this dataset"
25
+ when the reference lexicon knows it, and "unknown code(s)" when it does not. Before, every
26
+ such code was an unknown code: on SEER-Medicare, `M84.58XP` and `W56.22`, which OHDSI
27
+ has, were unknown codes. Each warning lists every code. The vocabulary operators
28
+ are also built from the reference lexicon's vocabularies, so an operator is not an
29
+ "invalid operator" only because the dataset lacks its vocabulary. The SQL and the counts
30
+ use the dataset's lexicon only, and do not change. See `docs/annotation.md`.
31
+ - `Query#annotate` puts an effective id first in each operator's annotation hash: the
32
+ operator's `id` option, or a generated `<operator>-<n>` in statement order (`icd9-1`,
33
+ `icd9-2`) that never repeats a given id. The statement's options do not change.
34
+
35
+ ### Removed
36
+
37
+ - **Breaking:** `Query#scope_annotate`, the CLI command `conceptql scope_annotate`, and the
38
+ scope hashes behind them (`Scope#annotation`, `#add_errors`, `#add_warnings` and
39
+ `#add_counts`). `Query#annotate`, `ConceptQL::Annotation` and `Query#errors`,
40
+ `#warnings` and `#valid?` are the only way to get errors, warnings and counts. The hash of
41
+ `scope_annotate` was keyed by the operator's name or `id` option, so of two operators with
42
+ the same key, only the last one's errors, warnings and counts were kept. The CLI command
43
+ `conceptql annotate_statement` remains.
44
+ - **Breaking:** the GDM lexicon (`ConceptQL::LexiconGDM`), which read the GDM vocabulary
45
+ tables `concepts`, `ancestors`, `mappings` and `vocabularies`. The lexicon now uses only
46
+ the OHDSI vocabulary tables (`concept`, `concept_ancestor`, `concept_relationship` and
47
+ `vocabulary`). A real data database whose search path, and whose `LEXICON_URL` database,
48
+ have only the GDM tables now raises `ConceptQL::Lexicon::NoVocabularyError`, and the
49
+ error lists only the missing OHDSI tables. The environment variables `LEXICON_GDM_ONLY`
50
+ and `LEXICON_OHDSI_ONLY` are removed. The test config `slim,gdm_vocabs` is removed from
51
+ CI and from `just test-full`, which now runs 2 Postgres configs. No production setup
52
+ read the GDM tables. The gdm data model stays.
53
+
54
+ ### Changed
55
+
56
+ - **Breaking:** `provenance` and `place_of_service_filter` give a warning, not an error, for
57
+ a keyword or code that the dataset's lexicon does not know, so the statement stays valid
58
+ and runs. The unknown keyword or code selects no record. They check it as the vocabulary
59
+ operators do (`ConceptQL::Database#check_codes`): "code(s) not present in this dataset"
60
+ when the reference lexicon knows it, "unknown code(s)" when it does not, and no warning
61
+ when no lexicon has the vocabulary (the 2020 rule). Each warning lists every keyword or
62
+ code. A `file:code` keyword of `provenance` is checked half by half, each half against its
63
+ own vocabularies, and a keyword with more than one `:` is unknown. Before, `provenance`
64
+ gave the error "unrecognized keywords" and `place_of_service_filter` the error "unknown
65
+ code(s)", and the plain query raised `InvalidStatementError`. So a statement could be
66
+ valid on one dataset and invalid on another. The checks run when the Database has a
67
+ lexicon but its data database is nil or a Sequel mock, and `skip_db: true` turns them off.
68
+ `provenance` with no keyword is still an error.
69
+ - **Breaking:** `cpt_or_hcpcs` checks its codes as one set across CPT4, HCPCS and
70
+ CPT4_HCPCS. A code is known when the dataset's lexicon has it in any of them. Of the other
71
+ codes, one that the reference lexicon has in any of them gets "code(s) not present in this
72
+ dataset", and the rest get "unknown code(s)". It accepts every code (the 2020 rule) only
73
+ when no lexicon of the Database has a concept of any of the three. Before, an empty CPT4 or
74
+ HCPCS accepted every code, so on
75
+ SEER-Medicare, which has only CPT4_HCPCS, `cpt_or_hcpcs` never warned about a code. When
76
+ `etl_vocabulary_ids` lists none of the three, `cpt_or_hcpcs` gets "the data has no records
77
+ of this vocabulary" once and null counts. Before, it got neither.
78
+ - **Breaking:** a vocabulary that has no concepts in the Database's lexicon no longer accepts
79
+ every code when the reference lexicon has concepts of it. Its codes get "code(s) not
80
+ present in this dataset" or "unknown code(s)". The 2020 rule (#185) now applies only when
81
+ no lexicon of the Database has the vocabulary: the dataset's lexicon and, when it is
82
+ given, the reference lexicon. `etl_vocabulary_ids` does not change the rule.
83
+ `ConceptQL::Database#check_codes` applies it for the vocabulary operators, `cpt_or_hcpcs`
84
+ and `information_periods`. So `information_periods` also asks the reference lexicon: with
85
+ a reference lexicon that has GDM_INFORMATION_TYPE, an information type code that it does
86
+ not know is "unknown code(s)", even when the dataset's lexicon lacks the vocabulary. See
87
+ the table in `docs/annotation.md`.
88
+ - A vocabulary operator that gets "the data has no records of this vocabulary" gets no
89
+ "code(s) not present in this dataset" warning, because that warning already says that none
90
+ of its codes is in the data. Its codes still get "unknown code(s)", "code(s) with more than
91
+ one meaning" and the format warning, so a typo is not hidden. `cpt_or_hcpcs` follows the
92
+ same rule for its set. So `["icd10pcs", "Z561123"]` on the test fixtures gives the
93
+ no-records warning and "unknown code(s)".
94
+ - **Breaking:** a vocabulary operator whose vocabulary is not in the dataset's GDM table
95
+ `etl_vocabulary_ids` gets the warning "the data has no records of this vocabulary", and
96
+ `Query#annotate` gives it null counts (`{"rows": null, "n": null}`). An operator whose
97
+ inputs all have null counts also has null counts. Before, such an operator counted 0 rows
98
+ with no warning, which said that no patient had the codes: on SEER-Medicare, `icd9cm`
99
+ counted 0 although the data has no ICD9CM records. A Query reads `etl_vocabulary_ids`
100
+ once. A dataset without the table, or with an empty one, gives no warning and the counts
101
+ of before. Operators that read another table than the clinical codes (`death_type`,
102
+ `obs_period_type`) and `cpt_or_hcpcs` are not checked. `cpt` and `hcpcs` with the
103
+ CPT4_HCPCS warning, which has the same text, get no second warning.
104
+ `ConceptQL::Diagram` keeps null counts (before, it made them 0), and
105
+ `ConceptQL::DiagramRenderer` draws them as `rows=NA` and `n=NA`. See `docs/annotation.md`.
106
+ - A vocabulary operator gives the unknown-code and the reused-code warnings, and
107
+ `information_periods` gives its unknown-code warning, when the Database has a lexicon but
108
+ its data database is nil or a Sequel mock. Before, these checks ran only with a real data
109
+ database, although they read only the lexicon, so the Jigsaw editor
110
+ (`ConceptQL::Database.new(lexicon_db)`) got no unknown-code warning. `skip_db: true` still
111
+ turns them off. The warning that the data stores CPT4 or HCPCS codes as CPT4_HCPCS reads the
112
+ data, so it still needs a real data database.
113
+ - **Breaking:** `InvalidStatementError#errors` and `Query#validation_errors` are a list of
114
+ every error, in the form of `ConceptQL::Annotation#errors`
115
+ (`{ operator:, id:, message:, details: }`). Before, they were a hash of operator name =>
116
+ list of `[message, *details]`, which kept the errors of only one of two operators with the
117
+ same name. The message of `InvalidStatementError` has one line for each error, as before.
118
+ `validation_errors` reads the kept result of `annotate(skip_counts: true)`, so `#sql` runs
119
+ no count SQL and does not annotate again.
120
+ - **Breaking:** `Query#annotate` of a window statement keeps the `window` operator at the top
121
+ of its output, with the window's options and an annotation hash that holds its id
122
+ (`window-1`). A window table that is a ConceptQL statement is annotated in place, in the
123
+ `window_table` option, so each of its errors stays on the operator that has it, and
124
+ `Annotation#valid?` is false. Before, `annotate` dropped the window operator, and the
125
+ window table's errors were only in `scope_annotate`, under the key `window_table`. An
126
+ invalid window table still makes `#sql` raise, and the outer statement is still not
127
+ counted. For example, `["window", ["icd9", "412"], {"window_table": ["provider_filter", ["icd9", "412"], {"specialties": "*"}]}]` now reports the error "required option not
128
+ present" (`roles`) on `provider_filter-1`.
129
+ - `union` and `co_reported` with exactly one upstream get the warning
130
+ `has only one upstream`. With one upstream, each passes the records of that upstream
131
+ through unchanged, which is almost never what the author meant. The statement stays valid,
132
+ and an operator with no upstream keeps the error `has no upstream`. The check is the new
133
+ class-level declaration `validate_at_least_two_upstreams`. Binary operators (`left` and
134
+ `right`) do not get the warning. Before, a one-input `union` or `co_reported` had no message.
135
+ - **Breaking:** `read` is a vocabulary operator, built from the new `Read` row of
136
+ `config/vocabularies.csv` like `icd9` or `ndc`, so it gets every vocabulary check. An
137
+ unknown Read code gets the warning `unknown code(s)` when the lexicon has the Read
138
+ vocabulary. So does a code in another case than the vocabulary stores, because Read codes
139
+ are case-sensitive: `13g4.00` and `13G4.00` are two codes. Before, `read` was the class
140
+ `ConceptQL::Operators::Read`, which wrapped one vocabulary operator, and it gave no
141
+ vocabulary warning. The class is removed. The records and the columns of `read` do not
142
+ change, and it keeps the lab columns (`value_as_number`, `range_high` and the others). A
143
+ `union` reads `read` in its own scan, so another vocabulary operator of the same domain in
144
+ the union keeps its own columns. In `ConceptQL.metadata`, `read` keeps its name, preferred
145
+ name `READ`, long name, description, category and domain. Its `short_name` is `READ`
146
+ (before, null), its `aliases` is an empty list (before, null), and its argument is `codes`
147
+ with the text of the other vocabulary operators (before, `read_codes`).
148
+
149
+ ### Fixed
150
+
151
+ - The warning `code(s) with more than one meaning` compares codes as the code lookup does: in
152
+ their own case, unless the vocabulary's vocabulary_formats row declares `(?i:...)`. Before,
153
+ two codes of a statement that differ only in case counted as one code with the names of
154
+ both.
155
+
156
+ - `Query#annotate` keeps one result for each options hash, and it annotates a fresh operator
157
+ tree for each new options hash. Before, a second call returned the first call's result
158
+ whatever its options, so `annotate` after `annotate(skip_counts: true)` gave zero counts.
159
+ A second call on a statement with an unknown operator whose `left` had a label also added a
160
+ false "duplicate label" error and changed the first result.
161
+
7
162
  ## [0.7.0] - 2026-11-09
8
163
 
9
164
  ### Added
data/README.md CHANGED
@@ -10,6 +10,10 @@ The ConceptQL specification is [docs/spec/README.md](docs/spec/README.md).
10
10
 
11
11
  `ConceptQL::DiagramRenderer` draws a statement's diagram as an SVG or a PNG. See [docs/diagram_renderer.md](docs/diagram_renderer.md).
12
12
 
13
+ `ConceptQL::Annotation` reads the errors, warnings and counts of an annotated statement. See [docs/annotation.md](docs/annotation.md).
14
+
15
+ The `reference_lexicon:` option of `ConceptQL::Database` gives a Database the full lexicon beside the lexicon of its dataset. Then a code that the dataset does not contain gets the warning "code(s) not present in this dataset", not "unknown code(s)". See [docs/annotation.md](docs/annotation.md#codes-that-the-dataset-does-not-contain).
16
+
13
17
  ## Motivation for ConceptQL
14
18
 
15
19
  Outcomes Insights has built a vast library of research algorithms and applied those algorithms to large databases of claims data. Early into building the library, we realized we had to overcome two major issues:
@@ -49,6 +49,7 @@ PLCSRVC,PLCSRVC,,,,
49
49
  Procedure Type,OMOP Procedure Occurrence Type,,procedure_occurrence,,
50
50
  PROVIDER_FACILITY_TYPE,PROVIDER_FACILITY_TYPE,,,,
51
51
  Race,Race and Ethnicity Code Set (USBC),,person,,
52
+ Read,NHS UK Read Codes Version 2 (HSCIC),READ,condition_occurrence,,
52
53
  Relationship,OMOP Relationship,,,,
53
54
  Revenue Code,UB04/CMS1450 Revenue Codes (CMS),Revenue Code,procedure_occurrence,revenue_code,
54
55
  RxNorm,RxNorm (NLM),,drug_exposure,,
data/docs/spec/README.md CHANGED
@@ -274,7 +274,7 @@ ConceptQL checks each statement before it builds SQL for it. A statement with an
274
274
 
275
275
  A warning does not make a statement invalid. Examples of warnings are an unknown code, an improperly formatted code and a deprecated operator. A statement with warnings runs. When ConceptQL draws the diagram of a statement, it shows the errors and the warnings of each operator, and it does not refuse an invalid statement.
276
276
 
277
- ConceptQL finds the concept of each code in the vocabulary tables. It uses either the OHDSI vocabulary tables (`concept`, `concept_ancestor`, `concept_relationship` and `vocabulary`) or the GDM vocabulary tables (`concepts`, `ancestors`, `mappings` and `vocabularies`). It looks for them in the search path of the data database, and then in the database that the environment variable `LEXICON_URL` names. If ConceptQL has a real data database and finds no complete set of vocabulary tables, it refuses to start. Without vocabulary tables, every code would match no records, and a statement would return no records with no warning. Without a database, for example when ConceptQL only draws a diagram, it uses no vocabulary tables.
277
+ ConceptQL finds the concept of each code in the vocabulary tables. It uses the OHDSI vocabulary tables (`concept`, `concept_ancestor`, `concept_relationship` and `vocabulary`). It looks for them in the search path of the data database, and then in the database that the environment variable `LEXICON_URL` names. If ConceptQL has a real data database and finds no complete set of vocabulary tables, it refuses to start. Without vocabulary tables, every code would match no records, and a statement would return no records with no warning. Without a database, for example when ConceptQL only draws a diagram, it uses no vocabulary tables. The vocabulary tables of a dataset can be trimmed to the codes that the data contains. Then ConceptQL can also read a full set of vocabulary tables, the reference lexicon, but only to tell a code that the dataset does not contain from a code that does not exist. When `LEXICON_URL` is set and the program names no other reference lexicon, the `LEXICON_URL` database is the reference lexicon.
278
278
 
279
279
  ## Selection Operators
280
280
 
@@ -376,8 +376,10 @@ Examples of how codes match:
376
376
  - `410*` matches nothing, because `*` is a wildcard only as a code alone.
377
377
  - A code that does not have the vocabulary's format gives the warning "improperly formatted code".
378
378
  - A code that the vocabulary does not contain gives the warning "unknown code(s)".
379
+ - When ConceptQL has a full lexicon (the reference lexicon) beside the vocabulary tables of the dataset, a code that only the full lexicon contains gives the warning "code(s) not present in this dataset", not "unknown code(s)". It is a warning, so the statement stays valid on a dataset that lacks some of its codes.
379
380
  - A code that has more than one meaning in the vocabulary gives the warning "code(s) with more than one meaning".
380
381
  - Some data stores CPT-4 and HCPCS codes only in the merged vocabulary CPT4_HCPCS. On that data, `cpt` and `hcpcs` select nothing, because they read only CPT4 and HCPCS. They give the warning "the data has no records of this vocabulary", which names `cpt_or_hcpcs`. Use `cpt_or_hcpcs`, which reads all three vocabularies.
382
+ - When the data lists the vocabularies of its records (the GDM table `etl_vocabulary_ids`) and the operator's vocabulary is not in the list, the operator gives the warning "the data has no records of this vocabulary". Its counts are null, not 0, because the data cannot show whether a patient has the codes. This warning covers all of its codes, so they get no other warning, except the warning for an improperly formatted code.
381
383
  - `rxnorm` does not find records coded with NDC, because an operator does not follow mappings between vocabularies.
382
384
 
383
385
  ### DRG Codes
@@ -1884,7 +1886,7 @@ Passes along the records whose place of service is one of the given CMS Place of
1884
1886
 
1885
1887
  - **Upstream:** one operator.
1886
1888
  - **Arguments:**
1887
- - `places_of_service` (codelist): One or more CMS Place of Service codes, such as 21 (inpatient hospital) or 22 (outpatient hospital). The code * matches a record whose place of service is any code of that vocabulary. An unknown code makes the statement invalid.
1889
+ - `places_of_service` (codelist): One or more CMS Place of Service codes, such as 21 (inpatient hospital) or 22 (outpatient hospital). The code * matches a record whose place of service is any code of that vocabulary. An unknown code gets a warning and matches no record.
1888
1890
  - **Returns:** The upstream records whose place of service is one of the given codes.
1889
1891
 
1890
1892
  ______________________________________________________________________
@@ -1909,7 +1911,7 @@ Passes along the records whose provenance matches at least one of the given keyw
1909
1911
 
1910
1912
  - **Upstream:** one operator.
1911
1913
  - **Arguments:**
1912
- - `provenance_types` (codelist): One or more provenance keywords, in lower case. A file keyword, such as inpatient, outpatient or carrier_claim, says which kind of file a record came from. A code keyword, such as primary or first, says where the code was on the claim. A plain keyword matches a record when its file provenance or its code provenance matches it. A keyword also matches the provenances under it, so inpatient includes snf. A keyword of the form file:code, such as inpatient:primary, needs both the file and the code provenance to match. The keyword * matches a record that has any provenance. A statement with no keyword, or with an unknown keyword, is invalid.
1914
+ - `provenance_types` (codelist): One or more provenance keywords, in lower case. A file keyword, such as inpatient, outpatient or carrier_claim, says which kind of file a record came from. A code keyword, such as primary or first, says where the code was on the claim. A plain keyword matches a record when its file provenance or its code provenance matches it. A keyword also matches the provenances under it, so inpatient includes snf. A keyword of the form file:code, such as inpatient:primary, needs both the file and the code provenance to match. The keyword * matches a record that has any provenance. A statement with no keyword is invalid. An unknown keyword gets a warning and matches no record.
1913
1915
  - **Returns:** The upstream records whose provenance matches at least one of the keywords.
1914
1916
 
1915
1917
  ______________________________________________________________________
@@ -191,10 +191,13 @@
191
191
  "children": [],
192
192
  "counts": {
193
193
  "condition_occurrence": {
194
- "rows": 0,
195
- "n": 0
194
+ "rows": null,
195
+ "n": null
196
196
  }
197
- }
197
+ },
198
+ "warnings": [
199
+ ["the data has no records of this vocabulary"]
200
+ ]
198
201
  }
199
202
  ],
200
203
  "counts": {
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ConceptQL
6
+ # Reads a statement that Query#annotate annotated: its operators, their
7
+ # errors and their warnings. It accepts the Ruby result of annotate and the
8
+ # same result parsed back from JSON (string keys), and gives the same answer
9
+ # for both. It reads the statement in its JSON form, so every hash it returns
10
+ # has string keys. See docs/annotation.md.
11
+ #
12
+ # An operator's inputs are its positional upstreams and its left, right and
13
+ # window_table options. A value is an operator only when it is an annotated
14
+ # operator: an Array that starts with a name and holds an annotation hash. So
15
+ # an option whose value is a list of strings is not an operator.
16
+ class Annotation
17
+ # One operator of the statement. #id is its effective id (see
18
+ # Annotation.assign_ids), and #annotation is its annotation hash.
19
+ Operator = Data.define(:name, :id, :annotation)
20
+
21
+ # The options that hold an operator's other inputs
22
+ INPUT_OPTIONS = %w[left right window_table].freeze
23
+
24
+ class << self
25
+ # Yields each operator node of an annotated statement and its annotation
26
+ # hash, in statement order: an operator, then its inputs in the order of
27
+ # its items.
28
+ def each_node(node, &block)
29
+ return enum_for(__method__, node) unless block
30
+
31
+ yield node, annotation_hash(node)
32
+ node.drop(1).each do |item|
33
+ if item.is_a?(Hash)
34
+ INPUT_OPTIONS.each do |key|
35
+ value = lookup(item, key)
36
+ each_node(value, &block) if operator?(value)
37
+ end
38
+ elsif operator?(item)
39
+ each_node(item, &block)
40
+ end
41
+ end
42
+ end
43
+
44
+ # Writes the effective id of each operator into its annotation hash, as
45
+ # the first key, :id. It is the operator's id option when the statement
46
+ # gives one. Otherwise it is "<operator>-<n>", where n counts the
47
+ # operators of that name in statement order (icd9-1, icd9-2), and
48
+ # skips any id that the statement gives. The options of the statement
49
+ # are not changed. Query#annotate calls it.
50
+ def assign_ids(annotated)
51
+ nodes = each_node(annotated).to_a
52
+ used = nodes.filter_map { |node, _| given_id(node)&.to_s }.to_set
53
+ counters = Hash.new(0)
54
+ nodes.each do |node, annotation|
55
+ id = given_id(node) || generate_id(node.first.to_s, counters, used)
56
+ annotation.replace({ id: id }.merge(annotation))
57
+ end
58
+ annotated
59
+ end
60
+
61
+ private
62
+
63
+ def generate_id(name, counters, used)
64
+ loop do
65
+ id = "#{name}-#{counters[name] += 1}"
66
+ return id if used.add?(id)
67
+ end
68
+ end
69
+
70
+ def given_id(node)
71
+ option(node, "id")
72
+ end
73
+
74
+ def annotation_hash(node)
75
+ option(node, "annotation")
76
+ end
77
+
78
+ def option(node, key)
79
+ node.drop(1).each do |item|
80
+ value = lookup(item, key) if item.is_a?(Hash)
81
+ return value unless value.nil?
82
+ end
83
+ nil
84
+ end
85
+
86
+ def operator?(value)
87
+ value.is_a?(Array) && (value.first.is_a?(String) || value.first.is_a?(Symbol)) &&
88
+ annotation_hash(value).is_a?(Hash)
89
+ end
90
+
91
+ def lookup(hash, key)
92
+ hash.fetch(key) { hash[key.to_sym] }
93
+ end
94
+ end
95
+
96
+ def initialize(annotated)
97
+ @annotated = JSON.parse(annotated.to_json)
98
+ end
99
+
100
+ # Every operator, root first, in statement order. Without a block, an
101
+ # Enumerator.
102
+ def each_operator
103
+ return enum_for(__method__) unless block_given?
104
+
105
+ self.class.each_node(@annotated) do |node, annotation|
106
+ yield Operator.new(name: node.first, id: annotation["id"], annotation: annotation)
107
+ end
108
+ end
109
+
110
+ # Every error, as { operator:, id:, message:, details: }
111
+ def errors
112
+ problems("errors")
113
+ end
114
+
115
+ # Every warning, in the same form as #errors
116
+ def warnings
117
+ problems("warnings")
118
+ end
119
+
120
+ # True when no operator has an error. Warnings do not make a statement
121
+ # invalid.
122
+ def valid?
123
+ errors.empty?
124
+ end
125
+
126
+ private
127
+
128
+ def problems(kind)
129
+ each_operator.flat_map do |op|
130
+ (op.annotation[kind] || []).map do |message, *details|
131
+ { operator: op.name, id: op.id, message: message, details: details }
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -27,7 +27,7 @@ module ConceptQL
27
27
  end
28
28
 
29
29
  # No keyword at all is a validation error of Provenance. It still builds
30
- # the condition of "*", as an invalid pair still builds a condition.
30
+ # the condition of "*", as an unknown pair still builds a condition.
31
31
  def build_where_from_codes(db, codes)
32
32
  return plain_condition(db, []) if codes.empty? || codes.include?(ANY)
33
33
 
@@ -39,19 +39,31 @@ module ConceptQL
39
39
  Sequel.|(*conditions)
40
40
  end
41
41
 
42
- # The keywords as written that name no provenance concept. A pair is
43
- # recognized only when it has one separator, a file keyword on the left
44
- # and a code keyword on the right. Case is compared exactly, as the lookup
45
- # compares it: the provenance vocabularies have no vocabulary_formats row
46
- # that declares case-insensitivity (seed conceptql-oji ruling "b"). So
47
- # "Inpatient" names no concept, and the concept codes are in lower case.
48
- # "*" is known. It is not a half of a pair.
49
- def find_bad_keywords(db, codes)
50
- pairs, plain = (codes - [ ANY ]).partition { |code| pair_keyword?(code) }
51
- known = known_keywords(db, all_provenance_types_vocab)
52
- bad = plain.reject { |code| known.include?(code.to_s) }
53
- bad += pairs.reject { |pair| known_pair?(db, pair) }
54
- codes & bad
42
+ # The keywords as written that the dataset's lexicon does not know, as
43
+ # [not_present, unknown] in the order written (bead conceptql-amuu).
44
+ # Database#check_codes sorts them with the 2020 rule, as it sorts the
45
+ # codes of a vocabulary operator: a keyword that the reference lexicon
46
+ # knows is not present in this dataset, and the rest are unknown.
47
+ #
48
+ # A plain keyword is checked against the file and the code provenance
49
+ # vocabularies together. A pair is checked half by half: the file keyword
50
+ # against the file vocabularies and the code keyword against the code
51
+ # vocabularies. A pair with an unknown half is unknown, and else a pair
52
+ # with a half that is not present is not present. A pair needs exactly
53
+ # one separator, or it is unknown.
54
+ #
55
+ # Case is compared exactly, as the lookup compares it: the provenance
56
+ # vocabularies have no vocabulary_formats row that declares
57
+ # case-insensitivity (seed conceptql-oji ruling "b"). So "Inpatient" names
58
+ # no concept, and the concept codes are in lower case. "*" is known. It is
59
+ # not a half of a pair.
60
+ def check_keywords(db, codes)
61
+ pairs, plain = (codes.map(&:to_s).uniq - [ ANY ]).partition { |code| pair_keyword?(code) }
62
+ not_present, unknown = check_codes(db, all_provenance_types_vocab, plain)
63
+ pair_not_present, pair_unknown = check_pairs(db, pairs)
64
+ [ not_present + pair_not_present, unknown + pair_unknown ].map do |bad|
65
+ codes.select { |code| bad.include?(code.to_s) }.uniq
66
+ end
55
67
  end
56
68
 
57
69
  private
@@ -60,25 +72,31 @@ module ConceptQL
60
72
  code.to_s.include?(PAIR_SEPARATOR)
61
73
  end
62
74
 
63
- def known_pair?(db, pair)
64
- file, code, *rest = pair.split(PAIR_SEPARATOR, -1)
65
- rest.empty? &&
66
- known_keywords(db, dm.file_provenance_types_vocab).include?(file) &&
67
- known_keywords(db, dm.code_provenance_types_vocab).include?(code)
75
+ # [not_present, unknown] of the pairs.
76
+ def check_pairs(db, pairs)
77
+ halves = pairs.to_h { |pair| [ pair, pair.split(PAIR_SEPARATOR, -1) ] }
78
+ malformed, formed = halves.partition { |_, parts| parts.size != 2 }.map(&:to_h)
79
+ files_not_present, files_unknown = check_codes(db, dm.file_provenance_types_vocab, formed.values.map(&:first).uniq)
80
+ codes_not_present, codes_unknown = check_codes(db, dm.code_provenance_types_vocab, formed.values.map(&:last).uniq)
81
+ unknown = formed.select { |_, (file, code)| files_unknown.include?(file) || codes_unknown.include?(code) }.keys
82
+ not_present = formed.select { |_, (file, code)| files_not_present.include?(file) || codes_not_present.include?(code) }.keys
83
+ [ not_present - unknown, malformed.keys + unknown ]
68
84
  end
69
85
 
70
- # The concept codes of the vocabularies.
71
- def known_keywords(db, vocabularies)
72
- dm.concept_codes(db, vocabularies)
86
+ # Database#check_codes, which is not asked about no codes.
87
+ def check_codes(db, vocabularies, codes)
88
+ return [ [], [] ] if codes.empty?
89
+
90
+ nodifier.cdb.check_codes(db, vocabularies, codes)
73
91
  end
74
92
 
75
93
  def all_provenance_types_vocab
76
94
  [ dm.file_provenance_types_vocab, dm.code_provenance_types_vocab ].flatten
77
95
  end
78
96
 
79
- # The keywords come from the statement, whose validation reports a bad
80
- # one (find_bad_keywords), so the lookup does not raise
81
- # CodeCaseMismatchError.
97
+ # The keywords come from the statement, whose validation warns about a
98
+ # bad one (check_keywords), so the lookup does not raise
99
+ # CodeCaseMismatchError. A keyword that names no concept selects nothing.
82
100
  def prov_of(db, vocabularies, ancestors)
83
101
  dm.descendants_of(db, dm.concept_ids(db, vocabularies, ancestors, check_case: false))
84
102
  end
@@ -90,7 +108,7 @@ module ConceptQL
90
108
  )
91
109
  end
92
110
 
93
- # An invalid pair (validation reports it) still builds a condition. A
111
+ # An unknown pair (validation warns about it) still builds a condition. A
94
112
  # half with no concept matches nothing, so the pair keeps no record.
95
113
  def pair_condition(db, pair)
96
114
  file, code = pair.split(PAIR_SEPARATOR, 2)
data/lib/conceptql/cli.rb CHANGED
@@ -77,12 +77,6 @@ module ConceptQL
77
77
  pp q.annotate(skip_counts: true)
78
78
  end
79
79
 
80
- desc "scope_annotate", "Reads in a statement and annotates it"
81
- def scope_annotate(statement_file)
82
- q = ConceptQL::Query.new(cdb(options), criteria_from_file(statement_file))
83
- pp q.scope_annotate(skip_counts: true)
84
- end
85
-
86
80
  desc "render_json STATEMENT_FILE",
87
81
  "Prints the statement(s) as a #{ConceptQL::Diagram::FORMAT} render tree (JSON) for the diagram renderer"
88
82
  long_desc <<~DESC
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "digest"
4
4
  require "logger"
5
+ # Database#db_extensions names Sequel::Mock::Database before any mock exists
6
+ require "sequel/adapters/mock"
5
7
  require_relative "lexicon"
6
8
 
7
9
  module ConceptQL
@@ -37,6 +39,7 @@ module ConceptQL
37
39
  if @lexicon && @opts[:cache_lookups]
38
40
  raise ArgumentError, "cache_lookups needs the Database to build its own Lexicon; do not pass lexicon:"
39
41
  end
42
+ @reference_lexicon = opts[:reference_lexicon] if opts[:reference_lexicon]
40
43
 
41
44
  @opts[:database_type] ||= (ENV["CONCEPTQL_DATABASE_TYPE"] || db_type).to_sym
42
45
  @opts[:scope_opts] = {
@@ -135,11 +138,78 @@ module ConceptQL
135
138
  @lexicon ||= Lexicon.new(self.class.lexicon_db, db, cache: @opts[:cache_lookups])
136
139
  end
137
140
 
141
+ # The reference lexicon: the full lexicon, beside the lexicon of the
142
+ # dataset (#lexicon), which can be trimmed to the codes that the data
143
+ # contains (bead conceptql-8oim, seed conceptql-d9w). Only the code checks
144
+ # (#check_codes) read it: a code that the dataset's lexicon does not know
145
+ # is "not present in this dataset" when the reference lexicon knows it,
146
+ # and an unknown code when it does not. Code-to-concept
147
+ # translation, the SQL and the counts use #lexicon only. The vocabulary
148
+ # operators are built from both, so an operator is valid whatever the
149
+ # dataset contains.
150
+ #
151
+ # It is the reference_lexicon: option, or else, when LEXICON_URL is set,
152
+ # a Lexicon of the LEXICON_URL database (Database.lexicon_db) with its own
153
+ # cache. It is nil when neither exists. A LEXICON_URL database without
154
+ # vocabulary tables raises.
155
+ def reference_lexicon
156
+ return @reference_lexicon if defined?(@reference_lexicon)
157
+
158
+ @reference_lexicon = (lexicon_url_lexicon(self.class.lexicon_db) if self.class.lexicon_db)
159
+ end
160
+
161
+ # The code check that every operator with codes calls (bead
162
+ # conceptql-dufi, seed conceptql-d9w). It returns [not_present, unknown]:
163
+ # of the codes that #lexicon does not know in any of the vocabularies,
164
+ # those that #reference_lexicon knows in any of them, and the rest. The
165
+ # caller gives "code(s) not present in this dataset" for the first and
166
+ # "unknown code(s)" for the second.
167
+ #
168
+ # The 2020 rule (refs #185): when no lexicon of the Database has a
169
+ # concept of any of the vocabularies, every code is accepted and both
170
+ # lists are empty. A lexicon that lacks a vocabulary cannot tell a real
171
+ # code from a typo. So with a reference lexicon, the rule applies only
172
+ # when both lexicons lack the vocabularies, and without one, when
173
+ # #lexicon lacks them. etl_vocabulary_ids does not change the rule.
174
+ #
175
+ # A code matches in its own case: a statement warns about a code in
176
+ # another case instead of raising CodeCaseMismatchError.
177
+ def check_codes(data_db, vocabulary_ids, codes)
178
+ vocabulary_ids = Array(vocabulary_ids).uniq
179
+ missing = codes - known_codes_in(lexicon, data_db, vocabulary_ids, codes)
180
+ return [ [], [] ] if missing.empty?
181
+
182
+ lexicons = [ lexicon, reference_lexicon ].compact
183
+ return [ [], [] ] if lexicons.all? { |lex| vocabulary_ids.all? { |id| lex.vocabulary_is_empty?(data_db, id) } }
184
+
185
+ not_present = reference_lexicon ? known_codes_in(reference_lexicon, data_db, vocabulary_ids, missing) : []
186
+ [ missing & not_present, missing - not_present ]
187
+ end
188
+
189
+ # The codes that a lexicon has in any of the vocabularies, with no
190
+ # exception for an empty vocabulary.
191
+ def known_codes_in(lex, data_db, vocabulary_ids, codes)
192
+ vocabulary_ids.flat_map do |vocabulary_id|
193
+ lex.known_codes(data_db, vocabulary_id, codes, check_case: false, empty_means_known: false)
194
+ end.uniq
195
+ end
196
+ private :known_codes_in
197
+
198
+ def lexicon_url_lexicon(lexicon_db)
199
+ reference = Lexicon.new(lexicon_db, nil, cache: true)
200
+ return reference unless reference.strategy == :no_db
201
+
202
+ raise Lexicon::NoVocabularyError, "the LEXICON_URL database has no vocabulary tables, " \
203
+ "so it cannot be the reference lexicon"
204
+ end
205
+ private :lexicon_url_lexicon
206
+
138
207
  # This Database's operators for a data model, as a frozen name => class
139
208
  # Hash: a vocabulary operator for every vocabulary in config/vocabularies.csv
140
- # (plus the custom file) and in this Database's lexicon, with the built-in
141
- # operators merged over them. A built-in wins a name clash (gender, race and
142
- # ethnicity are also vocabularies). Built lazily, once per data model.
209
+ # (plus the custom file), in this Database's lexicon and in its reference
210
+ # lexicon, with the built-in operators merged over them. A built-in wins a
211
+ # name clash (gender, race and ethnicity are also vocabularies). Built
212
+ # lazily, once per data model.
143
213
  #
144
214
  # The data model is an argument because a Nodifier can be given one that
145
215
  # differs from this Database's.
@@ -148,7 +218,7 @@ module ConceptQL
148
218
  ConceptQL::DataModel.assert_supported!(data_model)
149
219
  @operators ||= {}
150
220
  @operators[data_model] ||= begin
151
- vocabulary_operators = Vocabularies::DynamicVocabularies.new(lexicon).operators(data_model)
221
+ vocabulary_operators = Vocabularies::DynamicVocabularies.new(lexicon, reference_lexicon).operators(data_model)
152
222
  vocabulary_operators.merge(Operators.static_operators.fetch(data_model)).freeze
153
223
  end
154
224
  end
@@ -107,10 +107,13 @@ module ConceptQL
107
107
  (annotation[:counts] || {}).keys.flatten.map(&:to_s).uniq.sort
108
108
  end
109
109
 
110
+ # A null count stays null: the data has no records of the operator's
111
+ # vocabulary, so the count is unknown, not 0 (bead conceptql-d1bd).
112
+ # DiagramRenderer draws it as NA.
110
113
  def counts(annotation)
111
114
  (annotation[:counts] || {})
112
115
  .sort_by { |domain, _| domain.to_s }
113
- .to_h { |domain, c| [ domain.to_s, { rows: Integer(c[:rows] || 0), n: Integer(c[:n] || 0) } ] }
116
+ .to_h { |domain, c| [ domain.to_s, { rows: c[:rows]&.then { Integer(_1) }, n: c[:n]&.then { Integer(_1) } } ] }
114
117
  end
115
118
 
116
119
  def metadata_for(name)