@finbheara/domain 0.0.0-stage → 0.1.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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -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/place.js +36 -0
  13. package/dist/school.js +2240 -0
  14. package/dist/tune.js +23 -0
  15. package/dist/types/age-types.d.ts +31 -0
  16. package/dist/types/age.d.ts +84 -0
  17. package/dist/types/ceili.d.ts +53 -0
  18. package/dist/types/clrg-regions.d.ts +31 -0
  19. package/dist/types/dance-match.d.ts +28 -0
  20. package/dist/types/dance-types.d.ts +9 -0
  21. package/dist/types/dance.d.ts +14 -0
  22. package/dist/types/place-tree.d.ts +4 -0
  23. package/dist/types/place-types.d.ts +24 -0
  24. package/dist/types/place.d.ts +98 -0
  25. package/dist/types/school-compare.d.ts +36 -0
  26. package/dist/types/school-exception-vocabulary.d.ts +23 -0
  27. package/dist/types/school-fluff-vocabulary.d.ts +16 -0
  28. package/dist/types/school-fused-prefix.d.ts +1 -0
  29. package/dist/types/school-identity.d.ts +9 -0
  30. package/dist/types/school-institution-vocabulary.d.ts +20 -0
  31. package/dist/types/school-location.d.ts +16 -0
  32. package/dist/types/school-match-key.d.ts +5 -0
  33. package/dist/types/school-name.d.ts +82 -0
  34. package/dist/types/school-tokens.d.ts +20 -0
  35. package/dist/types/school.d.ts +9 -0
  36. package/dist/types/tune.d.ts +63 -0
  37. package/package.json +69 -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,199 @@
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
+
20
+ It was built on the stage announcements and results sheets of Irish dance competitions,
21
+ where one school arrives as `Ardvarna Academy of Irish Dance (Calgary, AB)` and as
22
+ `ARDVARNA ACADEMY - Calgary, Alberta`.
23
+
24
+ ```sh
25
+ npm install @finbheara/domain
26
+ ```
27
+
28
+ ESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT. Not affiliated with or endorsed by any dance commission.
29
+
30
+ ## Entry points
31
+
32
+ | import | what |
33
+ |---|---|
34
+ | `@finbheara/domain/place` | `place()`, `createPlaceReader`, `placeRelation`, the `Place` types |
35
+ | `@finbheara/domain/clrg-regions` | `REGIONS`, `regionOf`, `resolvePhrase` |
36
+ | `@finbheara/domain/school` | `createSchoolNameReader`, `DEFAULT_SCHOOL_NAME_READER`, `compareSchoolNames`, `foldSchoolName` |
37
+ | `@finbheara/domain/tune` | `tune()`, `tuneRelation`, `tempoLegal`, `settingAt`, `TUNE_NODES`: the CLRG set dance list |
38
+ | `@finbheara/domain/ceili` | `ceili()`, `ceiliRelation`, `CEILI_NODES`: the céilí handbook dances |
39
+ | `@finbheara/domain/dance` | `dance()`: a print read as a tune and a céilí dance, narrowed by its round |
40
+ | `@finbheara/domain/age` | `Age.from`, `Age.fromYOB`, `ageRelation`, `canOverlap`, `averageAge`, `isAveraged`, the age constants |
41
+ | `@finbheara/domain/dance-types` | `danceTypes()`, `DANCE_TYPES`, `rhythmOfTime`, and the shared `DanceMatch`/`DanceRelation`/`SetRhythm`/`TimeSignature` types |
42
+
43
+ The library does no I/O and installs nothing process-wide. Every reader has a default and a
44
+ factory: build your own with options when the default is not what you want.
45
+
46
+ ## Read a place
47
+
48
+ ```ts
49
+ import { place, placeRelation } from "@finbheara/domain/place";
50
+
51
+ const p = place("Pennsylvania, Mid-Atlantic Region");
52
+ p.arm; // "resolved"
53
+ p.pl2; // "USA-PA"
54
+ p.nodes.map((n) => n.id); // ["USA", "USA-PA", "IDTANA-MARO"]
55
+ p.country; // "USA"
56
+ p.region; // "IDTANA-MARO"
57
+
58
+ place("Calgary, AB").pl2; // "CAN-AB/calgary"
59
+ place("Co. Cork").region; // "MUNSTER"
60
+ place("CA").arm; // "ambiguous": California or Canada
61
+ place("Dublin").arm; // "ambiguous": Ireland, California or Ohio
62
+
63
+ placeRelation(place("Calgary, AB"), place("Western Canada")); // "contains"
64
+ placeRelation(place("Virginia"), place("Ontario")); // "conflict"
65
+ ```
66
+
67
+ A `Place` is one of four arms: `resolved`, `plural` (a print naming more than one place),
68
+ `ambiguous` (one print, several readings) and `unread`. Only `conflict` between two readings
69
+ says they cannot be one place; `same`, `contains` and `overlaps` do not.
70
+
71
+ ## Find a CLRG region
72
+
73
+ ```ts
74
+ import { REGIONS, regionOf, resolvePhrase } from "@finbheara/domain/clrg-regions";
75
+
76
+ regionOf("Pennsylvania"); // "MARO"
77
+ REGIONS.MARO; // "Mid-Atlantic Region USA"
78
+ resolvePhrase("Pennsylvania, Mid-Atlantic Region"); // ["MARO"]
79
+ resolvePhrase("Calgary in Western Canada"); // ["WCRO"]
80
+ resolvePhrase("Derry in Ireland"); // ["ULSTER"]
81
+ ```
82
+
83
+ `resolvePhrase` answers every region a phrase supports, and `[]` where it supports none. A
84
+ token that names places in two regions resolves only with a co-token that settles it.
85
+
86
+ ## Read a school name
87
+
88
+ ```ts
89
+ import { DEFAULT_SCHOOL_NAME_READER, compareSchoolNames } from "@finbheara/domain/school";
90
+
91
+ const reader = DEFAULT_SCHOOL_NAME_READER;
92
+ const s = reader.schoolName("Ardvarna Academy of Irish Dance (Calgary, AB)");
93
+ s.sn2; // "Ardvarna Academy (CAN-AB/calgary)" display form
94
+ s.sn3; // "ARDVARNA ACADEMY (CAN-AB/calgary)" filing key
95
+ s.institution; // "academy"
96
+ s.location?.pl3; // "CAN-AB/calgary"
97
+ s.isSchool(); // true
98
+
99
+ reader.schoolName("Scoil Rince Glenmorvan").sn2; // "S.R. Glenmorvan"
100
+ reader.schoolName("N/A").refusal?.reason; // "placeholder"
101
+
102
+ compareSchoolNames("Ardvarna Academy of Irish Dance (Calgary, AB)",
103
+ "ARDVARNA ACADEMY - Calgary, Alberta", reader).verdict; // "CANDIDATE"
104
+ compareSchoolNames("Ardvarna Academy (Calgary, AB)",
105
+ "Ardvarna Academy (Ontario)", reader).verdict; // "BLOCKED"
106
+ compareSchoolNames("Ardvarna Academy",
107
+ "Ardvarna Academy (Ontario)", reader).verdict; // "AMBIGUOUS"
108
+ compareSchoolNames("Ardvarna Academy", "Glenmorvan Academy", reader).verdict; // "DIFFERENT"
109
+ ```
110
+
111
+ The verdicts: `CANDIDATE` (same name, compatible qualifiers), `BLOCKED` (same name,
112
+ contradicting qualifiers), `AMBIGUOUS` (needs context) and `DIFFERENT`. A verdict is a name
113
+ reading and never an identity: two schools can share a name.
114
+
115
+ `DEFAULT_SCHOOL_NAME_READER` is the generic reader: no lexicon, the shipped registers, frozen.
116
+ Nothing replaces it, and the package holds no other. `compareSchoolNames` and
117
+ `schoolPrintSplit` take the reader as an argument, always, so a print is never read by a
118
+ reader you did not choose. A reader with a lexicon (for the surnames a school is named after),
119
+ your own exception and refusal registers, or your own garbage is one you build:
120
+
121
+ ```ts
122
+ import { compareSchoolNames, createSchoolNameReader } from "@finbheara/domain/school";
123
+
124
+ const reader = createSchoolNameReader({ lexicon: (key) => myLexicon.get(key) });
125
+ compareSchoolNames("Ardvarna Academy", "Ardvarna Academy (Ontario)", reader);
126
+ ```
127
+
128
+ ### Your own garbage
129
+
130
+ The shipped refusals are generic: a card number, keyboard filler, a run with no letter in it,
131
+ the shared placeholder list of `@finbheara/names` (`N/A`, `No School`, ...). What YOUR data
132
+ prints in a school field and is not a school, such as an entry system's own "school not
133
+ listed" instruction, a test entry, or a note typed into the wrong field, is yours to declare.
134
+ Pass it as `garbage`, the same idea as `phraseClassifier(lexicon, { garbageVocab })` in
135
+ `@finbheara/names`:
136
+
137
+ ```ts
138
+ import { createSchoolNameReader } from "@finbheara/domain/school";
139
+
140
+ const reader = createSchoolNameReader({
141
+ garbage: [
142
+ { pattern: "-- my school is not on this list --", kind: "exact", reason: "placeholder" },
143
+ { pattern: "demo school ", kind: "prefix", reason: "placeholder" },
144
+ { pattern: "^sister of .+$", kind: "regex", reason: "not-a-name" },
145
+ ],
146
+ });
147
+ reader.schoolName("-- My School Is Not On This List --").refusal?.reason; // "placeholder"
148
+ ```
149
+
150
+ A row's `pattern` is matched against the print with its case lowered, its accents and
151
+ invisible characters dropped and its whitespace collapsed; a `regex` is compiled with the `i`
152
+ flag and anchors itself. Your rows are asked before the shipped ones and the first match
153
+ answers.
154
+
155
+ ## Read an age
156
+
157
+ Ages are as of 1 January of the competition year. An `Age` is a closed range of birth years,
158
+ whatever it was read from, so a year of birth and a one-year age group are the same thing.
159
+
160
+ ```ts
161
+ import { Age, averageAge, canOverlap } from "@finbheara/domain/age";
162
+
163
+ const dancer = Age.fromYOB(2014);
164
+ dancer.isExact(); // true
165
+
166
+ const u12 = Age.from("U12", { year: 2026 });
167
+ u12.birthYears(); // { from: 2014, to: 2023 }
168
+ dancer.fitsIn(u12); // true
169
+
170
+ // With the age groups the event offered, U12 is one birth year, so it is exact.
171
+ const ladder = { caps: [11, 12, 13], overs: [], ranges: [] };
172
+ Age.from("U12", { year: 2026, ladder }).isExact(); // true
173
+
174
+ Age.fromYOB(1999).fitsIn(Age.from("U18", { year: 2025 })); // false
175
+ Age.fromYOB(1999).fitsIn(Age.from("Over 21", { year: 2026, top: true })); // true
176
+
177
+ canOverlap(u12, Age.from("U14", { year: 2026 }), dancer); // true
178
+ ```
179
+
180
+ A team's age is the average of its dancers' ages, rounded to two decimals. `averageAge`
181
+ takes exact ages only and returns an `AverageAge`, a separate type that refuses, by name,
182
+ any use that would treat it as one dancer's age.
183
+
184
+ ```ts
185
+ const team = averageAge([1990, 1985, 1999, 1988].map((y) => Age.fromYOB(y)));
186
+ team.meanAge(2026); // 34.5
187
+ team.fitsIn(Age.from("U40", { year: 2026 })); // true
188
+
189
+ averageAge([u12]); // throws AgeMisuseError "not-exact"
190
+ ```
191
+
192
+ ## Known gaps
193
+
194
+ - `place()` reads `"Derry in Ireland"` and `"Calgary in Western Canada"` as `unread`: it does
195
+ not split a phrase at `in`. `resolvePhrase` finds their regions.
196
+
197
+ ## Licence
198
+
199
+ MIT, Copyright (c) 2026 Finbheara Systems.
package/dist/age.js ADDED
@@ -0,0 +1,420 @@
1
+ var YOUNGEST_FLOOR = 0;
2
+ var ADULT_FLOOR = 18;
3
+ var MAJORS_FLOOR_YEARS = 3;
4
+ var UNDER_CAP = Object.freeze({ plain: 2, exceptional: 4 });
5
+ var UNDER_CAP_MAX_TOP = 21;
6
+ var YOUNG_FLOOR = Object.freeze({ hard: 2, soft: 4 });
7
+ var MIN_MAJOR_AGE = 4;
8
+ var MIN_FEIS_AGE = 3;
9
+ var DANCE_UP_YEARS = 1;
10
+ var ageOnJanuary1 = (year, born) => year - born - 1;
11
+ var yearsSinceBirth = (year, born) => year - born;
12
+ var UNDER = /^(?:u|under\s*)(\d+)$/i;
13
+ var OVER = /^(?:o|over\s*)(\d+)$/i;
14
+ var RANGE = /^u?(\d+)\s*[-–]\s*(\d+)$/i;
15
+ var ADULT_WORD = /^(?:adults?|ad|advanced|masters?)$/i;
16
+ var SENIOR_WORD = /^seniors?$/i;
17
+ var NO_LADDER = Object.freeze({ caps: [], overs: [], ranges: [] });
18
+ function maxWhere(xs, pred) {
19
+ let best;
20
+ for (const x of xs)
21
+ if (pred(x) && (best === undefined || x > best))
22
+ best = x;
23
+ return best;
24
+ }
25
+ function minWhere(xs, pred) {
26
+ let best;
27
+ for (const x of xs)
28
+ if (pred(x) && (best === undefined || x < best))
29
+ best = x;
30
+ return best;
31
+ }
32
+ function readRange(a, b, ladder) {
33
+ if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a)
34
+ return {
35
+ kind: "range",
36
+ interval: null,
37
+ refusal: "backwards-range",
38
+ clauses: [],
39
+ parts: [a, b]
40
+ };
41
+ const overlapping = ladder.ranges.includes(a) || ladder.ranges.includes(b + 1);
42
+ const bottom = !ladder.caps.some((c) => c <= a);
43
+ const atAdultFloor = bottom && a >= ADULT_FLOOR;
44
+ const youngest = bottom && !atAdultFloor;
45
+ const onRung = ladder.caps.includes(a);
46
+ const floor = overlapping || atAdultFloor || onRung ? a : Math.max(YOUNGEST_FLOOR, a - 1);
47
+ const clauses = [overlapping ? "range-overlapping" : "range-inclusive"];
48
+ if (youngest)
49
+ clauses.push("youngest-absorbs");
50
+ else if (atAdultFloor)
51
+ clauses.push("range-adult-floor");
52
+ else if (!overlapping)
53
+ clauses.push(onRung ? "range-floor-on-rung" : "range-floor-widened");
54
+ return {
55
+ kind: "range",
56
+ interval: { lo: youngest ? YOUNGEST_FLOOR : floor, hi: overlapping ? b : b + 1 },
57
+ refusal: null,
58
+ clauses,
59
+ parts: [a, b]
60
+ };
61
+ }
62
+ function read(token, ladder, top) {
63
+ const r = RANGE.exec(token);
64
+ if (r !== null)
65
+ return readRange(Number(r[1]), Number(r[2]), ladder);
66
+ if (ADULT_WORD.test(token))
67
+ return {
68
+ kind: "adult",
69
+ interval: { lo: ADULT_FLOOR, hi: Number.POSITIVE_INFINITY },
70
+ refusal: null,
71
+ clauses: ["and-over-no-ceiling"],
72
+ parts: []
73
+ };
74
+ if (SENIOR_WORD.test(token)) {
75
+ const top = maxWhere(ladder.caps, () => true);
76
+ return top === undefined ? { kind: "senior", interval: null, refusal: "senior-without-ladder", clauses: [], parts: [] } : {
77
+ kind: "senior",
78
+ interval: { lo: top, hi: Number.POSITIVE_INFINITY },
79
+ refusal: null,
80
+ clauses: ["and-over-no-ceiling"],
81
+ parts: []
82
+ };
83
+ }
84
+ const u = UNDER.exec(token);
85
+ if (u !== null) {
86
+ const n = Number(u[1]);
87
+ const below = maxWhere(ladder.caps, (c) => c < n);
88
+ return {
89
+ kind: "under",
90
+ interval: { lo: below ?? YOUNGEST_FLOOR, hi: n },
91
+ refusal: null,
92
+ clauses: [below === undefined ? "youngest-absorbs" : "under-floored-on-rung"],
93
+ parts: [n]
94
+ };
95
+ }
96
+ const o = OVER.exec(token);
97
+ if (o !== null) {
98
+ const n = Number(o[1]);
99
+ const above = top ? undefined : minWhere(ladder.overs, (c) => c > n);
100
+ if (above === undefined)
101
+ return {
102
+ kind: "over",
103
+ interval: { lo: n, hi: Number.POSITIVE_INFINITY },
104
+ refusal: null,
105
+ clauses: ["and-over-no-ceiling"],
106
+ parts: [n]
107
+ };
108
+ const gapped = ladder.caps.length > 0 && !ladder.caps.some((c) => c > n && c < above);
109
+ return {
110
+ kind: "over",
111
+ interval: { lo: n, hi: gapped ? Number.POSITIVE_INFINITY : above },
112
+ refusal: null,
113
+ clauses: [gapped ? "over-ceiling-refused-gap" : "over-ceiling-from-next-over"],
114
+ parts: [n]
115
+ };
116
+ }
117
+ return { kind: null, interval: null, refusal: "not-a-band", clauses: [], parts: [] };
118
+ }
119
+
120
+ class AgeMisuseError extends Error {
121
+ misuse;
122
+ constructor(misuse, detail) {
123
+ super(`age: ${misuse}: ${detail}`);
124
+ this.name = "AgeMisuseError";
125
+ this.misuse = misuse;
126
+ }
127
+ }
128
+ var display = (iv) => `[${iv.lo},${iv.hi === Number.POSITIVE_INFINITY ? "∞" : iv.hi})`;
129
+ var currentYear = () => new Date().getUTCFullYear();
130
+ function withRelations(a) {
131
+ const self = a;
132
+ return Object.assign(self, {
133
+ fitsIn: (container) => {
134
+ if (isAveraged(container))
135
+ throw new AgeMisuseError("average-as-container", `${self.ag2} cannot fit inside an average age (${String(container)}); an average is not a dancer`);
136
+ const r = ageRelation(self, container);
137
+ return r.relation === "same" || r.reason === "b-contains-a";
138
+ },
139
+ overlaps: (other) => {
140
+ const r = ageRelation(self, other).relation;
141
+ return r === "same" || r === "contains" || r === "overlaps";
142
+ }
143
+ });
144
+ }
145
+ function fromPrint(print, context = {}) {
146
+ const ladder = context.ladder ?? NO_LADDER;
147
+ const year = context.year === undefined ? currentYear() : context.year;
148
+ const ag1 = print.trim().toLowerCase();
149
+ const reading = ag1 === "" ? { kind: null, interval: null, refusal: null, clauses: [], parts: [] } : read(print.trim(), ladder, context.top === true);
150
+ const iv = reading.interval;
151
+ const arm = ag1 === "" ? "empty" : iv === null ? "open" : "band";
152
+ const ag2 = iv !== null ? display(iv) : ag1 === "" ? "" : `${ag1}?`;
153
+ const source = sourceOf(reading);
154
+ return withRelations({
155
+ [Symbol.toStringTag]: "Age",
156
+ toString: () => ag2,
157
+ arm,
158
+ kind: reading.kind,
159
+ ag0: print,
160
+ ag1,
161
+ ag2,
162
+ parts: reading.parts,
163
+ length: ag2.length,
164
+ refusal: reading.refusal,
165
+ source,
166
+ clauses: reading.clauses,
167
+ ladder,
168
+ year,
169
+ isEmpty: () => arm === "empty",
170
+ isAge: () => iv !== null,
171
+ isExact: () => {
172
+ if (iv === null || year === null)
173
+ return false;
174
+ const b = birthYearsOfInterval(iv, year);
175
+ return b.from !== null && b.from === b.to;
176
+ },
177
+ isAndOver: () => iv !== null && iv.hi === Number.POSITIVE_INFINITY,
178
+ isAdult: () => reading.kind === "adult",
179
+ interval: () => iv,
180
+ birthYears: (y) => {
181
+ const at = y ?? year;
182
+ if (at === null)
183
+ throw new Error(`Age.from(${JSON.stringify(print)}): birthYears needs a competition year`);
184
+ return birthYearsOfInterval(iv, at);
185
+ },
186
+ floorObserved: () => reading.kind !== "under" || iv === null ? null : reading.clauses.includes("under-floored-on-rung")
187
+ });
188
+ }
189
+ function fromYOB(from, to = from) {
190
+ for (const y of [from, to])
191
+ if (y !== null && !Number.isInteger(y))
192
+ throw new RangeError(`Age.fromYOB(${from}, ${to}): a year of birth is a whole number`);
193
+ if (from === null && to === null)
194
+ throw new RangeError("Age.fromYOB(null, null): at least one end is a year");
195
+ if (from !== null && to !== null && from > to)
196
+ throw new RangeError(`Age.fromYOB(${from}, ${to}): the earliest year comes first`);
197
+ const exact = from !== null && from === to;
198
+ const ag0 = exact ? String(from) : `${from ?? ""}..${to ?? ""}`;
199
+ const ag2 = `b${ag0}`;
200
+ const years = Object.freeze({ from, to });
201
+ return withRelations({
202
+ [Symbol.toStringTag]: "Age",
203
+ toString: () => ag2,
204
+ arm: "yob",
205
+ kind: "yob",
206
+ ag0,
207
+ ag1: ag0,
208
+ ag2,
209
+ parts: exact ? [from] : [from, to].filter((y) => y !== null),
210
+ length: ag2.length,
211
+ refusal: null,
212
+ source: "printed-yob",
213
+ clauses: [],
214
+ ladder: NO_LADDER,
215
+ year: null,
216
+ isEmpty: () => false,
217
+ isAge: () => true,
218
+ isExact: () => exact,
219
+ isAndOver: () => from === null,
220
+ isAdult: () => false,
221
+ interval: () => null,
222
+ birthYears: () => years,
223
+ floorObserved: () => null
224
+ });
225
+ }
226
+ var Age = Object.freeze({ from: fromPrint, fromYOB });
227
+ function sourceOf(r) {
228
+ const iv = r.interval;
229
+ if (iv === null)
230
+ return "open";
231
+ switch (r.kind) {
232
+ case "range":
233
+ return "printed-range";
234
+ case "over":
235
+ return iv.hi === Number.POSITIVE_INFINITY ? "printed-band" : "sibling-narrowed";
236
+ case "adult":
237
+ case "senior":
238
+ return "printed-band";
239
+ case "under":
240
+ return iv.lo > YOUNGEST_FLOOR ? "sibling-narrowed" : "printed-band";
241
+ default:
242
+ return "open";
243
+ }
244
+ }
245
+ function birthYearsOfInterval(iv, year) {
246
+ if (iv === null)
247
+ return { from: null, to: null };
248
+ return {
249
+ from: iv.hi === Number.POSITIVE_INFINITY ? null : year - iv.hi,
250
+ to: Math.min(year - iv.lo - 1, year - MAJORS_FLOOR_YEARS)
251
+ };
252
+ }
253
+ var inBirthYears = (a) => a.arm === "yob" || a.year !== null;
254
+ function spanOf(a) {
255
+ if (!a.isAge())
256
+ return null;
257
+ if (inBirthYears(a)) {
258
+ const b = a.birthYears();
259
+ return {
260
+ lo: b.from ?? Number.NEGATIVE_INFINITY,
261
+ hi: b.to ?? Number.POSITIVE_INFINITY
262
+ };
263
+ }
264
+ const iv = a.interval();
265
+ return { lo: iv.lo, hi: iv.hi - 1 };
266
+ }
267
+ function ageRelation(a, b) {
268
+ for (const x of [a, b])
269
+ if (isAveraged(x))
270
+ throw new AgeMisuseError("average-in-relation", `${String(x)} is an average, not a dancer's birth years; ask its fitsIn`);
271
+ if (inBirthYears(a) !== inBirthYears(b))
272
+ throw new Error("ageRelation: one reading is in birth years and the other has no competition year; read both with a year, or neither");
273
+ if (!a.isAge())
274
+ return { relation: "silent", reason: "a-not-a-band" };
275
+ if (!b.isAge())
276
+ return { relation: "silent", reason: "b-not-a-band" };
277
+ const sa = spanOf(a);
278
+ const sb = spanOf(b);
279
+ const years = inBirthYears(a);
280
+ if (sa.hi < sb.lo)
281
+ return { relation: "disjoint", reason: years ? "a-older" : "a-younger" };
282
+ if (sb.hi < sa.lo)
283
+ return { relation: "disjoint", reason: years ? "a-younger" : "a-older" };
284
+ if (sa.lo === sb.lo && sa.hi === sb.hi)
285
+ return { relation: "same", reason: "equal" };
286
+ if (sa.lo <= sb.lo && sb.hi <= sa.hi)
287
+ return { relation: "contains", reason: "a-contains-b" };
288
+ if (sb.lo <= sa.lo && sa.hi <= sb.hi)
289
+ return { relation: "contains", reason: "b-contains-a" };
290
+ return { relation: "overlaps", reason: "partial" };
291
+ }
292
+ function canOverlap(...ages) {
293
+ for (let i = 0;i < ages.length; i++)
294
+ for (let j = i + 1;j < ages.length; j++)
295
+ if (ageRelation(ages[i], ages[j]).relation === "disjoint")
296
+ return false;
297
+ return true;
298
+ }
299
+ var AVERAGE_AGE_DECIMALS = 2;
300
+ var HUNDREDTHS = 10 ** AVERAGE_AGE_DECIMALS;
301
+ function roundedMeanHundredths(k, n) {
302
+ if (!Number.isInteger(k) || !Number.isInteger(n) || n <= 0)
303
+ throw new RangeError(`roundedMeanHundredths(${k}, ${n}): whole numbers, n > 0`);
304
+ return Math.floor((2 * HUNDREDTHS * k + n) / (2 * n));
305
+ }
306
+ function isAveraged(a) {
307
+ return a[Symbol.toStringTag] === "AverageAge";
308
+ }
309
+ function averageAge(ages) {
310
+ if (ages.length === 0)
311
+ throw new AgeMisuseError("empty-average", "averageAge needs at least one exact age");
312
+ let sum = 0;
313
+ for (const [i, a] of ages.entries()) {
314
+ if (isAveraged(a))
315
+ throw new AgeMisuseError("average-of-averages", `input ${i} (${String(a)}) is already an average; average the members instead`);
316
+ if (!a.isExact())
317
+ throw new AgeMisuseError("not-exact", `input ${i} (${String(a)}) is not one birth year; an average takes exact ages only`);
318
+ sum += a.birthYears().from;
319
+ }
320
+ const n = ages.length;
321
+ const members = Object.freeze([...ages]);
322
+ const hundredths = (year) => roundedMeanHundredths(n * (year - 1) - sum, n);
323
+ const self = {
324
+ [Symbol.toStringTag]: "AverageAge",
325
+ provenance: `average of ${n}`,
326
+ members,
327
+ n,
328
+ birthYearSum: sum,
329
+ meanBirthYear: sum / n,
330
+ toString: () => `avg(${n}):b${sum / n}`,
331
+ isExact: () => false,
332
+ meanAgeHundredths: hundredths,
333
+ meanAge: (year) => hundredths(year) / HUNDREDTHS,
334
+ fitsIn: (container) => {
335
+ if (isAveraged(container))
336
+ throw new AgeMisuseError("average-as-container", `nothing fits inside an average age (${String(container)})`);
337
+ if (container.year === null)
338
+ throw new AgeMisuseError("average-container-no-year", `${String(container)} has no competition year to take the average age in`);
339
+ const iv = container.interval();
340
+ if (iv === null)
341
+ return false;
342
+ const h = hundredths(container.year);
343
+ return iv.lo * HUNDREDTHS <= h && h < iv.hi * HUNDREDTHS;
344
+ },
345
+ birthYears: () => {
346
+ throw new AgeMisuseError("average-birth-years", `an average of ${n} has no year of birth; read meanBirthYear, which is not a dancer's`);
347
+ }
348
+ };
349
+ return self;
350
+ }
351
+ function birthYearsDisjoint(a, b) {
352
+ if (a.to !== null && b.from !== null && a.to < b.from)
353
+ return true;
354
+ if (b.to !== null && a.from !== null && b.to < a.from)
355
+ return true;
356
+ return false;
357
+ }
358
+ function birthYearsMeet(card, other, slack = 0) {
359
+ if (card.from !== null && other.to !== null && card.from > other.to)
360
+ return false;
361
+ if (card.to !== null && other.from !== null && card.to + slack < other.from)
362
+ return false;
363
+ return true;
364
+ }
365
+ function underCapApplies(bandFrom, year) {
366
+ return bandFrom !== null && year - bandFrom <= UNDER_CAP_MAX_TOP;
367
+ }
368
+ function underCappedYears(b, year, n = UNDER_CAP.plain) {
369
+ if (n > UNDER_CAP.exceptional)
370
+ throw new Error(`under-cap: ${n} is over the exceptional cap of ${UNDER_CAP.exceptional}`);
371
+ if (!underCapApplies(b.from, year))
372
+ return b;
373
+ const cap = b.from + n;
374
+ if (b.to !== null && b.to <= cap)
375
+ return b;
376
+ return { from: b.from, to: cap };
377
+ }
378
+ function underCapRefuses(bandFrom, identityFrom, n) {
379
+ return identityFrom > bandFrom + n;
380
+ }
381
+ function youngFloor(year, oldest) {
382
+ const s = yearsSinceBirth(year, oldest);
383
+ if (s >= YOUNG_FLOOR.soft)
384
+ return "clear";
385
+ if (s < YOUNG_FLOOR.hard)
386
+ return "too-young";
387
+ return "same-school-only";
388
+ }
389
+ var tooYoungForMajor = (year, oldest) => yearsSinceBirth(year, oldest) < MIN_MAJOR_AGE;
390
+ var tooYoungForFeis = (year, oldest) => yearsSinceBirth(year, oldest) < MIN_FEIS_AGE;
391
+ export {
392
+ ADULT_FLOOR,
393
+ AVERAGE_AGE_DECIMALS,
394
+ Age,
395
+ AgeMisuseError,
396
+ DANCE_UP_YEARS,
397
+ MAJORS_FLOOR_YEARS,
398
+ MIN_FEIS_AGE,
399
+ MIN_MAJOR_AGE,
400
+ UNDER_CAP,
401
+ UNDER_CAP_MAX_TOP,
402
+ YOUNGEST_FLOOR,
403
+ YOUNG_FLOOR,
404
+ ageOnJanuary1,
405
+ ageRelation,
406
+ averageAge,
407
+ birthYearsDisjoint,
408
+ birthYearsMeet,
409
+ birthYearsOfInterval,
410
+ canOverlap,
411
+ isAveraged,
412
+ roundedMeanHundredths,
413
+ tooYoungForFeis,
414
+ tooYoungForMajor,
415
+ underCapApplies,
416
+ underCapRefuses,
417
+ underCappedYears,
418
+ yearsSinceBirth,
419
+ youngFloor
420
+ };
package/dist/ceili.js ADDED
@@ -0,0 +1,15 @@
1
+ import {
2
+ CEILI_NODES2,
3
+ allCeilis2,
4
+ ceiliById2,
5
+ ceili2,
6
+ ceiliRelation2
7
+ } from "./chunks/place-7gqecaxd.js";
8
+ import"./chunks/place-g6yw4cq6.js";
9
+ export {
10
+ CEILI_NODES2 as CEILI_NODES,
11
+ allCeilis2 as allCeilis,
12
+ ceili2 as ceili,
13
+ ceiliById2 as ceiliById,
14
+ ceiliRelation2 as ceiliRelation
15
+ };