@finbheara/domain 0.0.0-stage → 0.2.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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -2
  3. package/dist/age.js +420 -0
  4. package/dist/ceili.js +15 -0
  5. package/dist/chunks/place-7gqecaxd.js +201 -0
  6. package/dist/chunks/place-g6yw4cq6.js +134 -0
  7. package/dist/chunks/place-kr42294h.js +267 -0
  8. package/dist/chunks/place-qbp3xfb4.js +7412 -0
  9. package/dist/clrg-regions.js +603 -0
  10. package/dist/dance-types.js +14 -0
  11. package/dist/dance.js +41 -0
  12. package/dist/noun-index.js +233 -0
  13. package/dist/place.js +36 -0
  14. package/dist/school.js +2269 -0
  15. package/dist/tune.js +23 -0
  16. package/dist/types/age-types.d.ts +31 -0
  17. package/dist/types/age.d.ts +84 -0
  18. package/dist/types/ceili.d.ts +53 -0
  19. package/dist/types/clrg-regions.d.ts +31 -0
  20. package/dist/types/dance-match.d.ts +28 -0
  21. package/dist/types/dance-types.d.ts +9 -0
  22. package/dist/types/dance.d.ts +14 -0
  23. package/dist/types/noun-index.d.ts +67 -0
  24. package/dist/types/place-tree.d.ts +4 -0
  25. package/dist/types/place-types.d.ts +24 -0
  26. package/dist/types/place.d.ts +98 -0
  27. package/dist/types/school-compare.d.ts +39 -0
  28. package/dist/types/school-encoder.d.ts +8 -0
  29. package/dist/types/school-exception-vocabulary.d.ts +23 -0
  30. package/dist/types/school-fluff-vocabulary.d.ts +16 -0
  31. package/dist/types/school-fused-prefix.d.ts +1 -0
  32. package/dist/types/school-identity.d.ts +9 -0
  33. package/dist/types/school-institution-vocabulary.d.ts +20 -0
  34. package/dist/types/school-location.d.ts +16 -0
  35. package/dist/types/school-match-key.d.ts +5 -0
  36. package/dist/types/school-name.d.ts +82 -0
  37. package/dist/types/school-tokens.d.ts +20 -0
  38. package/dist/types/school.d.ts +11 -0
  39. package/dist/types/tune.d.ts +63 -0
  40. package/package.json +73 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Finbheara Systems
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,274 @@
1
- # Temporary Holding Version
1
+ # @finbheara/domain
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Places, CLRG regions, school names, ages and dances as they are actually printed: read,
4
+ compared and told apart. A domain model downstream of
5
+ [`@finbheara/names`](https://www.npmjs.com/package/@finbheara/names), which reads the
6
+ person names.
7
+
8
+ - **Places**: a print in, the set of place-tree nodes it may mean out. A country, a state or
9
+ province, a county, a city, or a CLRG region, with a relation between any two readings.
10
+ - **CLRG regions**: the CLRG's dance regions and their Oireachtas codes, and the region a
11
+ location phrase falls in.
12
+ - **School names**: a printed school name in, a `SchoolName` out, with a display form, a
13
+ filing key and a verdict on whether two prints may be one school.
14
+ - **Ages**: an age group (`U12`, `Over 21`, `13-14`) or a year of birth in, an `Age` out: a
15
+ closed range of birth years, exact when it is one year, with `fitsIn`, `overlaps` and a
16
+ team's `averageAge`.
17
+ - **Dances**: a spoken or printed set dance or céilí dance in, the dances it may name out,
18
+ with tempo rules and hand counts.
19
+ - **A database-backed index** of any of these nouns: the slots an in-memory name map hashes
20
+ on, stored as indexed columns of a SQLite table you own.
21
+
22
+ It was built on the stage announcements and results sheets of Irish dance competitions,
23
+ where one school arrives as `Ardvarna Academy of Irish Dance (Calgary, AB)` and as
24
+ `ARDVARNA ACADEMY - Calgary, Alberta`.
25
+
26
+ ```sh
27
+ npm install @finbheara/domain
28
+ ```
29
+
30
+ ESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT. Not affiliated with or endorsed by any dance commission.
31
+
32
+ ## Entry points
33
+
34
+ | import | what |
35
+ |---|---|
36
+ | `@finbheara/domain/place` | `place()`, `createPlaceReader`, `placeRelation`, the `Place` types |
37
+ | `@finbheara/domain/clrg-regions` | `REGIONS`, `regionOf`, `resolvePhrase` |
38
+ | `@finbheara/domain/school` | `createSchoolNameReader`, `DEFAULT_SCHOOL_NAME_READER`, `compareSchoolNames`, `foldSchoolName`, `schoolEncoder`, `schoolColumns` |
39
+ | `@finbheara/domain/tune` | `tune()`, `tuneRelation`, `tempoLegal`, `settingAt`, `TUNE_NODES`: the CLRG set dance list |
40
+ | `@finbheara/domain/ceili` | `ceili()`, `ceiliRelation`, `CEILI_NODES`: the céilí handbook dances |
41
+ | `@finbheara/domain/dance` | `dance()`: a print read as a tune and a céilí dance, narrowed by its round |
42
+ | `@finbheara/domain/age` | `Age.from`, `Age.fromYOB`, `ageRelation`, `canOverlap`, `averageAge`, `isAveraged`, the age constants |
43
+ | `@finbheara/domain/noun-index` | `createIndex`, `indexSchema`, `personEncoder`, and the `SqlHandle`/`NounEncoder` types: a database-backed name map (below) |
44
+ | `@finbheara/domain/dance-types` | `danceTypes()`, `DANCE_TYPES`, `rhythmOfTime`, and the shared `DanceMatch`/`DanceRelation`/`SetRhythm`/`TimeSignature` types |
45
+
46
+ The library does no I/O and installs nothing process-wide. Every reader has a default and a
47
+ factory: build your own with options when the default is not what you want.
48
+
49
+ ## Read a place
50
+
51
+ ```ts
52
+ import { place, placeRelation } from "@finbheara/domain/place";
53
+
54
+ const p = place("Pennsylvania, Mid-Atlantic Region");
55
+ p.arm; // "resolved"
56
+ p.pl2; // "USA-PA"
57
+ p.nodes.map((n) => n.id); // ["USA", "USA-PA", "IDTANA-MARO"]
58
+ p.country; // "USA"
59
+ p.region; // "IDTANA-MARO"
60
+
61
+ place("Calgary, AB").pl2; // "CAN-AB/calgary"
62
+ place("Co. Cork").region; // "MUNSTER"
63
+ place("CA").arm; // "ambiguous": California or Canada
64
+ place("Dublin").arm; // "ambiguous": Ireland, California or Ohio
65
+
66
+ placeRelation(place("Calgary, AB"), place("Western Canada")); // "contains"
67
+ placeRelation(place("Virginia"), place("Ontario")); // "conflict"
68
+ ```
69
+
70
+ A `Place` is one of four arms: `resolved`, `plural` (a print naming more than one place),
71
+ `ambiguous` (one print, several readings) and `unread`. Only `conflict` between two readings
72
+ says they cannot be one place; `same`, `contains` and `overlaps` do not.
73
+
74
+ ## Find a CLRG region
75
+
76
+ ```ts
77
+ import { REGIONS, regionOf, resolvePhrase } from "@finbheara/domain/clrg-regions";
78
+
79
+ regionOf("Pennsylvania"); // "MARO"
80
+ REGIONS.MARO; // "Mid-Atlantic Region USA"
81
+ resolvePhrase("Pennsylvania, Mid-Atlantic Region"); // ["MARO"]
82
+ resolvePhrase("Calgary in Western Canada"); // ["WCRO"]
83
+ resolvePhrase("Derry in Ireland"); // ["ULSTER"]
84
+ ```
85
+
86
+ `resolvePhrase` answers every region a phrase supports, and `[]` where it supports none. A
87
+ token that names places in two regions resolves only with a co-token that settles it.
88
+
89
+ ## Read a school name
90
+
91
+ ```ts
92
+ import { DEFAULT_SCHOOL_NAME_READER, compareSchoolNames } from "@finbheara/domain/school";
93
+
94
+ const reader = DEFAULT_SCHOOL_NAME_READER;
95
+ const s = reader.schoolName("Ardvarna Academy of Irish Dance (Calgary, AB)");
96
+ s.sn2; // "Ardvarna Academy (CAN-AB/calgary)" display form
97
+ s.sn3; // "ARDVARNA ACADEMY (CAN-AB/calgary)" filing key
98
+ s.institution; // "academy"
99
+ s.location?.pl3; // "CAN-AB/calgary"
100
+ s.isSchool(); // true
101
+
102
+ reader.schoolName("Scoil Rince Glenmorvan").sn2; // "S.R. Glenmorvan"
103
+ reader.schoolName("N/A").refusal?.reason; // "placeholder"
104
+
105
+ compareSchoolNames("Ardvarna Academy of Irish Dance (Calgary, AB)",
106
+ "ARDVARNA ACADEMY - Calgary, Alberta", reader).verdict; // "CANDIDATE"
107
+ compareSchoolNames("Ardvarna Academy (Calgary, AB)",
108
+ "Ardvarna Academy (Ontario)", reader).verdict; // "BLOCKED"
109
+ compareSchoolNames("Ardvarna Academy",
110
+ "Ardvarna Academy (Ontario)", reader).verdict; // "AMBIGUOUS"
111
+ compareSchoolNames("Ardvarna Academy", "Glenmorvan Academy", reader).verdict; // "DIFFERENT"
112
+ ```
113
+
114
+ The verdicts: `CANDIDATE` (same name, compatible qualifiers), `BLOCKED` (same name,
115
+ contradicting qualifiers), `AMBIGUOUS` (needs context) and `DIFFERENT`. A verdict is a name
116
+ reading and never an identity: two schools can share a name.
117
+
118
+ `DEFAULT_SCHOOL_NAME_READER` is the generic reader: no lexicon, the shipped registers, frozen.
119
+ Nothing replaces it, and the package holds no other. `compareSchoolNames` and
120
+ `schoolPrintSplit` take the reader as an argument, always, so a print is never read by a
121
+ reader you did not choose. A reader with a lexicon (for the surnames a school is named after),
122
+ your own exception and refusal registers, or your own garbage is one you build:
123
+
124
+ ```ts
125
+ import { compareSchoolNames, createSchoolNameReader } from "@finbheara/domain/school";
126
+
127
+ const reader = createSchoolNameReader({ lexicon: (key) => myLexicon.get(key) });
128
+ compareSchoolNames("Ardvarna Academy", "Ardvarna Academy (Ontario)", reader);
129
+ ```
130
+
131
+ ### Your own garbage
132
+
133
+ The shipped refusals are generic: a card number, keyboard filler, a run with no letter in it,
134
+ the shared placeholder list of `@finbheara/names` (`N/A`, `No School`, ...). What YOUR data
135
+ prints in a school field and is not a school, such as an entry system's own "school not
136
+ listed" instruction, a test entry, or a note typed into the wrong field, is yours to declare.
137
+ Pass it as `garbage`, the same idea as `phraseClassifier(lexicon, { garbageVocab })` in
138
+ `@finbheara/names`:
139
+
140
+ ```ts
141
+ import { createSchoolNameReader } from "@finbheara/domain/school";
142
+
143
+ const reader = createSchoolNameReader({
144
+ garbage: [
145
+ { pattern: "-- my school is not on this list --", kind: "exact", reason: "placeholder" },
146
+ { pattern: "demo school ", kind: "prefix", reason: "placeholder" },
147
+ { pattern: "^sister of .+$", kind: "regex", reason: "not-a-name" },
148
+ ],
149
+ });
150
+ reader.schoolName("-- My School Is Not On This List --").refusal?.reason; // "placeholder"
151
+ ```
152
+
153
+ A row's `pattern` is matched against the print with its case lowered, its accents and
154
+ invisible characters dropped and its whitespace collapsed; a `regex` is compiled with the `i`
155
+ flag and anchors itself. Your rows are asked before the shipped ones and the first match
156
+ answers.
157
+
158
+ ## Read an age
159
+
160
+ Ages are as of 1 January of the competition year. An `Age` is a closed range of birth years,
161
+ whatever it was read from, so a year of birth and a one-year age group are the same thing.
162
+
163
+ ```ts
164
+ import { Age, averageAge, canOverlap } from "@finbheara/domain/age";
165
+
166
+ const dancer = Age.fromYOB(2014);
167
+ dancer.isExact(); // true
168
+
169
+ const u12 = Age.from("U12", { year: 2026 });
170
+ u12.birthYears(); // { from: 2014, to: 2023 }
171
+ dancer.fitsIn(u12); // true
172
+
173
+ // With the age groups the event offered, U12 is one birth year, so it is exact.
174
+ const ladder = { caps: [11, 12, 13], overs: [], ranges: [] };
175
+ Age.from("U12", { year: 2026, ladder }).isExact(); // true
176
+
177
+ Age.fromYOB(1999).fitsIn(Age.from("U18", { year: 2025 })); // false
178
+ Age.fromYOB(1999).fitsIn(Age.from("Over 21", { year: 2026, top: true })); // true
179
+
180
+ canOverlap(u12, Age.from("U14", { year: 2026 }), dancer); // true
181
+ ```
182
+
183
+ A team's age is the average of its dancers' ages, rounded to two decimals. `averageAge`
184
+ takes exact ages only and returns an `AverageAge`, a separate type that refuses, by name,
185
+ any use that would treat it as one dancer's age.
186
+
187
+ ```ts
188
+ const team = averageAge([1990, 1985, 1999, 1988].map((y) => Age.fromYOB(y)));
189
+ team.meanAge(2026); // 34.5
190
+ team.fitsIn(Age.from("U40", { year: 2026 })); // true
191
+
192
+ averageAge([u12]); // throws AgeMisuseError "not-exact"
193
+ ```
194
+
195
+ ## Index names in a database
196
+
197
+ `createNameMap` (from `@finbheara/names`) and `mapOfSchools` hold their names in memory. An
198
+ index holds them in a SQLite table, as columns: every slot those maps hash a name on, indexed,
199
+ so a lookup at a level is one equality and nothing is loaded. It is a multi-map: a row is one
200
+ name and one value, and a lookup answers every value filed at the name's slot.
201
+
202
+ The package depends on no SQLite module. Hand it a handle with `prepare(sql)` returning a
203
+ statement with `run(...)` and `all(...)`: `bun:sqlite`'s `Database` and `node:sqlite`'s
204
+ `DatabaseSync` (Node ≥ 22.5) are both one as they are.
205
+
206
+ ```ts
207
+ import { Database } from "bun:sqlite"; // or: import { DatabaseSync } from "node:sqlite"
208
+ import { createNameMap, personName } from "@finbheara/names/normalize";
209
+ import { createIndex, indexSchema, personEncoder } from "@finbheara/domain/noun-index";
210
+
211
+ const db = new Database(":memory:");
212
+ const people = personEncoder();
213
+ const { table, indexes } = indexSchema(people, { table: "person" });
214
+ db.prepare(table).run();
215
+ for (const s of indexes) db.prepare(s).run(); // after a bulk load, if you have one
216
+
217
+ const index = createIndex(db, people, { table: "person" });
218
+ index.add(personName("Aoife Ní Mhaolvarna"), 1);
219
+ index.add(personName("Mhaolvarna, Aoife Ni"), 2);
220
+ index.add(personName("Kestrel Evbatyrov"), 3);
221
+
222
+ index.get(personName("AOIFE NI MHAOLVARNA"), "nn3"); // [1, 2]
223
+ index.get(personName("Aoife Ní Mhaolvarna"), "nn0"); // [1]
224
+ index.find("surname_key", personName("Aoife Ní Mhaolvarna").surnameKey()); // [1, 2]
225
+ index.nouns(2)[0]?.nn2; // "NiMhaolvarna<<Aoife": the parse, read back
226
+ ```
227
+
228
+ `map(level)` is the single-valued view, with `createNameMap`'s interface and its answers:
229
+ the first name filed at a slot keeps it, a later `set` replaces the value, and a print that
230
+ names no person is held and never found.
231
+
232
+ ```ts
233
+ const byName = index.map("nn3");
234
+ byName.get(personName("Aoife Ni Mhaolvarna")); // 1
235
+ byName.size; // 2
236
+ createNameMap<number>("nn3", [...byName]).size; // 2
237
+ ```
238
+
239
+ The person columns are the print, the reading it was parsed under, and `nameColumns()` from
240
+ `@finbheara/names`; the levels are `nn0`-`nn4`. A school's (`schoolEncoder` from
241
+ `@finbheara/domain/school`) are the print, `sn0`-`sn4`, `realWords` and `matchKey`, and its
242
+ levels are `mapOfSchools`'s. One print may be filed under several schools, and a school under
243
+ many prints:
244
+
245
+ ```ts
246
+ import { DEFAULT_SCHOOL_NAME_READER, schoolEncoder } from "@finbheara/domain/school";
247
+
248
+ const read = (p: string) => DEFAULT_SCHOOL_NAME_READER.schoolName(p);
249
+ const schools = createIndex(db, schoolEncoder(), { table: "school" }); // after its indexSchema
250
+ schools.add(read("Ardvarna Academy"), 10);
251
+ schools.add(read("Ardvarna Academy"), 11);
252
+ schools.add(read("Ardvarna Academy (Ontario)"), 11);
253
+ schools.add(read("ARDVARNA ACADEMY - Calgary, Alberta"), 10);
254
+
255
+ schools.get(read("Ardvarna Academy"), "sn0"); // [10, 11]
256
+ schools.nouns(11).map((s) => s.sn0); // ["Ardvarna Academy", "Ardvarna Academy (Ontario)"]
257
+ schools.get(read("Ardvarna Academy of Irish Dance (Calgary, AB)"), "sn3"); // [10]
258
+ ```
259
+
260
+ `rows({ column: keys, ... })` answers every row where any column holds one of its keys, and
261
+ `glob(column, pattern)` a SQLite `GLOB` on one column. An encoder is a plain object: add a
262
+ column of your own by spreading one, `{ ...enc, columns: [...enc.columns, "k"], encode: (x) =>
263
+ ({ ...enc.encode(x), k: f(x) }) }`. `indexSchema` takes the table, the value column and its
264
+ type (`{ value: "id", valueType: "INTEGER PRIMARY KEY" }` makes the value the row id); on such a table `map(level)` iterates in value order, and a `set` to a value another row
265
+ holds is refused by the key).
266
+
267
+ ## Known gaps
268
+
269
+ - `place()` reads `"Derry in Ireland"` and `"Calgary in Western Canada"` as `unread`: it does
270
+ not split a phrase at `in`. `resolvePhrase` finds their regions.
271
+
272
+ ## Licence
273
+
274
+ MIT, Copyright (c) 2026 Finbheara Systems.