@@ -4,6 +4,12 @@ import type {
44 CatalogCard ,
55 CatalogAttribute ,
66} from "./cohortCatalog" ;
7+ import {
8+ findAttributeAcrossCards ,
9+ findAttributeByName ,
10+ findCardByName ,
11+ primaryConceptAttribute ,
12+ } from "./cohortCatalog" ;
713import type { CohortConstraint , CohortExpression } from "./cohortBookmarkTree" ;
814
915/**
@@ -38,42 +44,37 @@ export interface ResolverDeps {
3844
3945const NUM_OPS = new Set ( [ ">=" , "<=" , "<" , ">" , "=" , "!=" ] ) ;
4046
41- function norm ( s : string ) : string {
42- return s . trim ( ) . toLowerCase ( ) ;
43- }
47+ /** Ops a category/text attribute accepts. `in` OR-s a list of stored tokens. */
48+ const CATEGORY_OPS = new Set ( [ "=" , "!=" , "in" , "not in" ] ) ;
4449
45- /** Find a card by display name (exact ci, then substring). */
46- function findCard (
50+ /**
51+ * "Card X has no attribute Y" — plus, when Y exists elsewhere, where to put it.
52+ *
53+ * The bare version of this error is what ended a real session with "the Visit
54+ * card doesn't have an age attribute" and no cohort: age is a patient
55+ * attribute, one clause away, and the model had no way to know that from the
56+ * rejection alone.
57+ */
58+ function unknownAttributeError (
4759 catalog : CohortCatalog ,
48- name : string ,
49- ) : CatalogCard | undefined {
50- const n = norm ( name ) ;
51- return (
52- catalog . cards . find ( ( c ) => norm ( c . name ) === n ) ??
53- catalog . cards . find (
54- ( c ) => norm ( c . name ) . includes ( n ) || n . includes ( norm ( c . name ) ) ,
55- )
56- ) ;
57- }
58-
59- /** Find an attribute within a card by display name (exact ci, then substring). */
60- function findAttr (
6160 card : CatalogCard ,
62- name : string ,
63- ) : CatalogAttribute | undefined {
64- const n = norm ( name ) ;
65- return (
66- card . attributes . find ( ( a ) => norm ( a . name ) === n ) ??
67- card . attributes . find (
68- ( a ) => norm ( a . name ) . includes ( n ) || n . includes ( norm ( a . name ) ) ,
69- )
61+ requested : string ,
62+ ) : Error {
63+ const available = card . attributes . map ( ( a ) => a . name ) . join ( ", " ) || "(none)" ;
64+ const elsewhere = findAttributeAcrossCards ( catalog , requested ) . filter (
65+ ( hit ) => hit . card . key !== card . key ,
7066 ) ;
71- }
72-
73- /** The card's primary concept-set attribute (cohortDefinitionKey "CodesetId"). */
74- function primaryConceptAttr ( card : CatalogCard ) : CatalogAttribute | undefined {
75- return card . attributes . find (
76- ( a ) => a . kind === "conceptSet" && a . cohortDefinitionKey === "CodesetId" ,
67+ const hint = elsewhere . length
68+ ? ` "${ elsewhere [ 0 ] . attribute . name } " IS available on card "${ elsewhere [ 0 ] . card . name } "` +
69+ `${
70+ elsewhere . length > 1
71+ ? ` (also: ${ elsewhere . slice ( 1 ) . map ( ( h ) => `"${ h . card . name } "` ) . join ( ", " ) } )`
72+ : ""
73+ } — move that constraint into its own clause for that card instead of ` +
74+ `dropping it. Demographics (age, gender, race) always live on the patient card.`
75+ : "" ;
76+ return new Error (
77+ `Card "${ card . name } " has no attribute "${ requested } ". Available: ${ available } .${ hint } ` ,
7778 ) ;
7879}
7980
@@ -150,6 +151,56 @@ function numExpressions(c: ClauseConstraint): {
150151 return { expressions : [ { operator : c . op , value : v } ] , combine : "OR" } ;
151152}
152153
154+ /**
155+ * Build category/text expressions, resolving each raw term to the token the
156+ * dataset actually stores.
157+ *
158+ * A list (`op:"in"`, or an array value) becomes several OR-ed expressions on the
159+ * one attribute. That matters for real questions: an encounter-type column
160+ * splits "an ER visit" across several tokens ("Emergency Room Visit",
161+ * "Emergency Room and Inpatient Visit"), and forcing one token per constraint
162+ * would quietly answer a narrower question than the user asked. Negation is
163+ * AND-ed instead — "neither A nor B" is not "not A or not B".
164+ */
165+ async function categoryExpressions (
166+ c : ClauseConstraint ,
167+ card : CatalogCard ,
168+ attr : CatalogAttribute ,
169+ deps : ResolverDeps ,
170+ ) : Promise < { expressions : CohortExpression [ ] ; combine : "AND" | "OR" } > {
171+ const op = String ( c . op ?? "=" ) . trim ( ) . toLowerCase ( ) ;
172+ if ( ! CATEGORY_OPS . has ( op ) ) {
173+ throw new Error (
174+ `Unsupported operator "${ c . op } " for text attribute "${ attr . name } ". Use ` +
175+ `"=" (or "in" with a list of values to match any of them), or "!=" / ` +
176+ `"not in" to exclude.` ,
177+ ) ;
178+ }
179+ const raws = ( Array . isArray ( c . value ) ? c . value : [ c . value ] )
180+ . map ( ( v ) => String ( v ?? "" ) . trim ( ) )
181+ . filter ( Boolean ) ;
182+ if ( raws . length === 0 ) {
183+ throw new Error ( `Constraint on "${ attr . name } " has no value.` ) ;
184+ }
185+
186+ const negate = op === "!=" || op === "not in" ;
187+ const resolved : string [ ] = [ ] ;
188+ // Sequential on purpose: the resolver's fetches share a per-attribute cache,
189+ // so the second token reuses the first one's domain read instead of racing it.
190+ for ( const raw of raws ) {
191+ const value = await deps . resolveValue ( card , attr , raw ) ;
192+ if ( ! resolved . includes ( value ) ) resolved . push ( value ) ;
193+ }
194+
195+ return {
196+ expressions : resolved . map ( ( value ) => ( {
197+ operator : negate ? "!=" : "=" ,
198+ value,
199+ } ) ) ,
200+ combine : negate ? "AND" : "OR" ,
201+ } ;
202+ }
203+
153204/**
154205 * Resolve clauses to constraints. Throws an actionable Error on any clause that
155206 * can't be resolved (unknown card/attribute, missing concept attribute,
@@ -164,7 +215,7 @@ export async function resolveClausesToConstraints(
164215
165216 for ( let i = 0 ; i < clauses . length ; i ++ ) {
166217 const clause = clauses [ i ] ;
167- const card = findCard ( catalog , clause . card ) ;
218+ const card = findCardByName ( catalog , clause . card ) ;
168219 if ( ! card ) {
169220 throw new Error (
170221 `Unknown filter card "${ clause . card } ". Available: ${ catalog . cards
@@ -196,7 +247,7 @@ export async function resolveClausesToConstraints(
196247 // the agent already resolved the id via the concept-set tools).
197248 if ( hasConcept ) {
198249 await assertValidConceptSetId ( clause . conceptSetId , card . name , deps ) ;
199- const attr = primaryConceptAttr ( card ) ;
250+ const attr = primaryConceptAttribute ( card ) ;
200251 if ( ! attr ) {
201252 throw new Error (
202253 `Card "${ card . name } " has no concept set to attach concept set ${ clause . conceptSetId } to.` ,
@@ -212,25 +263,22 @@ export async function resolveClausesToConstraints(
212263
213264 // 2. explicit attribute constraints.
214265 for ( const cc of clause . constraints ?? [ ] ) {
215- const attr = findAttr ( card , cc . attribute ) ;
266+ const attr = findAttributeByName ( card , cc . attribute ) ;
216267 if ( ! attr ) {
217- throw new Error (
218- `Card "${ card . name } " has no attribute "${ cc . attribute } ". Available: ${ card . attributes
219- . map ( ( a ) => a . name )
220- . join ( ", " ) } .`,
221- ) ;
268+ throw unknownAttributeError ( catalog , card , cc . attribute ) ;
222269 }
223270
224271 let expressions : CohortExpression [ ] ;
225272 let combine : "AND" | "OR" ;
226273 if ( attr . kind === "num" ) {
227274 ( { expressions, combine } = numExpressions ( cc ) ) ;
228275 } else if ( attr . kind === "category" ) {
229- const resolved = await deps . resolveValue ( card , attr , String ( cc . value ) ) ;
230- expressions = [
231- { operator : cc . op === "!=" ? "!=" : "=" , value : resolved } ,
232- ] ;
233- combine = "OR" ;
276+ ( { expressions, combine } = await categoryExpressions (
277+ cc ,
278+ card ,
279+ attr ,
280+ deps ,
281+ ) ) ;
234282 } else if ( attr . kind === "conceptSet" ) {
235283 // value IS the concept-set id (agent-resolved), e.g. a unit set.
236284 await assertValidConceptSetId ( cc . value , card . name , deps ) ;
0 commit comments