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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +4 -0
- data/config/vocabularies.csv +1 -0
- data/docs/spec/README.md +5 -3
- data/docs/spec/examples/recall-operator.json +6 -3
- data/lib/conceptql/annotation.rb +136 -0
- data/lib/conceptql/behaviors/provenanceable.rb +44 -26
- data/lib/conceptql/cli.rb +0 -6
- data/lib/conceptql/database.rb +74 -4
- data/lib/conceptql/diagram.rb +4 -1
- data/lib/conceptql/diagram_renderer/geometry.rb +6 -1
- data/lib/conceptql/lexicon/lexicon_strategy.rb +23 -12
- data/lib/conceptql/lexicon.rb +24 -35
- data/lib/conceptql/nodifier.rb +16 -0
- data/lib/conceptql/operators/co_reported.rb +1 -0
- data/lib/conceptql/operators/information_periods.rb +7 -3
- data/lib/conceptql/operators/multiple_vocabularies.rb +42 -22
- data/lib/conceptql/operators/operator.rb +38 -8
- data/lib/conceptql/operators/place_of_service_filter.rb +24 -12
- data/lib/conceptql/operators/provenance.rb +11 -11
- data/lib/conceptql/operators/recall.rb +5 -0
- data/lib/conceptql/operators/union.rb +13 -2
- data/lib/conceptql/operators/vocabulary.rb +73 -24
- data/lib/conceptql/query.rb +111 -37
- data/lib/conceptql/scope.rb +1 -18
- data/lib/conceptql/version.rb +1 -1
- data/lib/conceptql/vocabularies/drg_systems.rb +3 -3
- data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +15 -10
- data/lib/conceptql/vocabularies/entry.rb +2 -2
- data/lib/conceptql/vocabularies/read_codes.rb +42 -0
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +3 -0
- metadata +3 -3
- data/lib/conceptql/lexicon/lexicon_gdm.rb +0 -50
- data/lib/conceptql/operators/read.rb +0 -79
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 296389d035ad781649b32662a3fafba66aa209dea6c76fe039b372463854293f
|
|
4
|
+
data.tar.gz: 9e7225d0b68c8dd72a7b6b6ade8f3ffdb67b3b1c8cd6507e53e8d84a1fa0a6bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
data/config/vocabularies.csv
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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":
|
|
195
|
-
"n":
|
|
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
|
|
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
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
#
|
|
71
|
-
def
|
|
72
|
-
|
|
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
|
|
80
|
-
# one (
|
|
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
|
|
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
|
data/lib/conceptql/database.rb
CHANGED
|
@@ -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)
|
|
141
|
-
# operators merged over them. A built-in wins a
|
|
142
|
-
# ethnicity are also vocabularies). Built
|
|
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
|
data/lib/conceptql/diagram.rb
CHANGED
|
@@ -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:
|
|
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)
|