@finbheara/domain 0.4.0 → 0.6.2

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.
package/README.md CHANGED
@@ -36,10 +36,10 @@ ESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT. Not affiliated with or endorsed by any d
36
36
  | `@finbheara/domain/place` | `place()`, `createPlaceReader`, `placeRelation`, the `Place` types |
37
37
  | `@finbheara/domain/clrg-regions` | `REGIONS`, `regionOf`, `resolvePhrase` |
38
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 |
39
+ | `@finbheara/domain/tune` | `tune()`, `tuneRelation`, `tempoLegal`, `settingAt`, `TUNE_NODES`: the CLRG set dance list, each tune with its time, speeds and bars |
40
40
  | `@finbheara/domain/ceili` | `ceili()`, `ceiliRelation`, `CEILI_NODES`: the céilí handbook dances |
41
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`, `intersectBirthYears`, the `Validated` type, the age constants |
42
+ | `@finbheara/domain/age` | `Age.from`, `Age.fromYOB`, `Age.fromTitle`, `ageTitleToken`, `ageRelation`, `canOverlap`, `averageAge`, `isAveraged`, `intersectBirthYears`, `validYOB`, the age constants |
43
43
  | `@finbheara/domain/noun-index` | `createIndex`, `indexSchema`, `personEncoder`, and the `SqlHandle`/`NounEncoder` types: a database-backed name map (below) |
44
44
  | `@finbheara/domain/dance-types` | `danceTypes()`, `DANCE_TYPES`, `rhythmOfTime`, and the shared `DanceMatch`/`DanceRelation`/`SetRhythm`/`TimeSignature` types |
45
45
  | `@finbheara/domain/promotion` | `samePromotedRow`, `samePromotedSchool`, `samePromotedHeir`, `samePromotedName`, `questionMarksRead`, `foldCharSynonyms`: is a row the same row after a promotion (below) |
@@ -47,6 +47,37 @@ ESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT. Not affiliated with or endorsed by any d
47
47
  The library does no I/O and installs nothing process-wide. Every reader has a default and a
48
48
  factory: build your own with options when the default is not what you want.
49
49
 
50
+ ## Empty, refused, valid
51
+
52
+ A reader never throws on what was printed. Every value it returns (`Place`, `SchoolName`,
53
+ `Age`, `TuneReading`, `CeiliReading`, `DanceReading`) is `Validated`, and answers three
54
+ questions:
55
+
56
+ | method | true when |
57
+ |---|---|
58
+ | `isEmpty()` | nothing was printed: an empty or blank cell, no dance named |
59
+ | `isRefused()` | something was printed and the type declines it: a placeholder, a school in a place field, a URL or an online event in a place field, `U2`, a `1900` year of birth |
60
+ | `isValid()` | neither: `!isEmpty() && !isRefused()` |
61
+
62
+ `invalidReason()` is `null` when the value is valid, `"empty"` when it is empty, and the
63
+ refusal's reason otherwise, in the type's own words. Every entry point exports the `Validated`
64
+ type.
65
+
66
+ Valid does not mean usable. A print the reader does not know but cannot rule out is valid:
67
+ `place("Ardmore")` is valid and `isPlace()` is false. Ask the type's own question (`isPlace()`,
68
+ `isSchool()`, `isAge()`, `isTune()`, `isCeili()`) for a value you can use.
69
+
70
+ ```ts
71
+ place("").invalidReason(); // "empty"
72
+ place("n/a").invalidReason(); // "placeholder"
73
+ place("Ardmore").isValid(); // true: valid, unknown
74
+ place("Video Feis").invalidReason(); // "online"
75
+ DEFAULT_SCHOOL_NAME_READER.schoolName("N/A").isRefused(); // true
76
+ Age.fromYOB(1900).invalidReason(); // "year-out-of-range"
77
+ tune("").isEmpty(); // true
78
+ tune("Reel").invalidReason(); // "dance-type"
79
+ ```
80
+
50
81
  ## Read a place
51
82
 
52
83
  ```ts
@@ -72,6 +103,13 @@ A `Place` is one of four arms: `resolved`, `plural` (a print naming more than on
72
103
  `ambiguous` (one print, several readings) and `unread`. Only `conflict` between two readings
73
104
  says they cannot be one place; `same`, `contains` and `overlaps` do not.
74
105
 
106
+ A print that is clearly not a place is refused, with its reason: a placeholder
107
+ (`[address withheld]`, `Other`), a URL anywhere in the print (`web`), an online event in the
108
+ venue field (`online`), a change-of-venue notice (`notice`), or a replacement character where
109
+ the source lost bytes (`damaged`). These are asked only where the store reads nothing, so a
110
+ refusal never takes a reading away. Mojibake that can be decoded back is not refused: repair it
111
+ before reading.
112
+
75
113
  ## Find a CLRG region
76
114
 
77
115
  ```ts
@@ -156,6 +194,31 @@ invisible characters dropped and its whitespace collapsed; a `regex` is compiled
156
194
  flag and anchors itself. Your rows are asked before the shipped ones and the first match
157
195
  answers.
158
196
 
197
+ ## Read a set dance
198
+
199
+ `tune()` reads a spoken or printed set dance as the tunes of the official list it may name. A
200
+ stated tempo is taken out of the print and narrows the reading. Each tune answers its time
201
+ signature, its speed (a fixed speed for the seven traditional sets, a minimum for any
202
+ contemporary set) and the bars of music in its two parts, the step and the set.
203
+
204
+ ```ts
205
+ import { tempoLegal, tune } from "@finbheara/domain/tune";
206
+
207
+ const r = tune("Kilkenny Races at 80");
208
+ r.tune?.name; // "Kilkenny Races"
209
+ r.bpm; // 80
210
+ r.tune?.time(); // "4/4"
211
+ tempoLegal(r, 80); // true: hornpipe time, at or above 76
212
+ r.tune?.stepBars(); // 8
213
+ r.tune?.setBars(); // 24
214
+
215
+ tune("Blackbird").tune?.speed(); // 144: a traditional set's fixed speed
216
+ tune("Planxty").candidates.length; // 3: Davis, Drury and Hugh O'Donnell
217
+ ```
218
+
219
+ The bars are data on the tune and never read from a print: they say how long a step and a
220
+ set run at a given speed, and are the same in a traditional and a contemporary round.
221
+
159
222
  ## Read an age
160
223
 
161
224
  Ages are as of 1 January of the competition year. An `Age` is a closed range of birth years,
@@ -179,19 +242,51 @@ Age.fromYOB(1999).fitsIn(Age.from("U18", { year: 2025 })); // fals
179
242
  Age.fromYOB(1999).fitsIn(Age.from("Over 21", { year: 2026, top: true })); // true
180
243
 
181
244
  canOverlap(u12, Age.from("U14", { year: 2026 }), dancer); // true
245
+
246
+ Age.from("13 & Over", { year: 2026 }).interval(); // { lo: 13, hi: Infinity }, as "Over 13"
182
247
  ```
183
248
 
184
- A value the reader cannot stand behind is returned, not thrown. Every `Age` answers
185
- `isValid()` and `invalidReason()`: an age group under `U3` admits nobody old enough to dance,
186
- and a year of birth outside 1900 to 2099 is not one. An invalid `Age` has no birth years and
187
- rules nothing out.
249
+ A competition title usually carries its age group in its words. `Age.fromTitle` finds the
250
+ phrase, reads it as `Age.from` would, and says where it was. A title that names no age group
251
+ is empty, not refused. Competition numbers are never read as ages.
252
+
253
+ ```ts
254
+ const t = Age.fromTitle("Novice Hornpipe Under 10", { year: 2026 });
255
+ t.token; // "u10"
256
+ t.span; // { start: 16, end: 24, text: "Under 10" }
257
+ t.age.birthYears(); // { from: 2016, to: 2023 }
258
+
259
+ Age.fromTitle("Open Championship 15-16", { year: 2026 }).token; // "15-16"
260
+ Age.fromTitle("Competition 719OC - Open Championship (O18)").token; // "o18", not "o9"
261
+ Age.fromTitle("Treble Reel Special", { year: 2026 }).age.isEmpty(); // true
262
+ Age.fromTitle("Mixed Reel 7 & Under").token; // "u8": a 7-year-old dances in it
263
+ Age.fromTitle("Girls 8 and under 9 years").token; // "8-9"
264
+ Age.fromTitle("Junior Boys 12 Years").token; // "u13"
265
+ Age.fromTitle("10-11 Single Jig").token; // "10-11", a band, not a number
266
+ Age.fromTitle("Competition 001-013 - Reel").token; // "", a competition number
267
+ Age.fromTitle("Senior Boys Reel 15&16").token; // "15-16": a range beats the word
268
+ Age.fromTitle("Beginner Adults Reel U40").token; // "adult": a split of the adult band
269
+ Age.fromTitle("Adult / Child Reel Under 10").token; // "u10": no adult dances in it
270
+ ```
271
+
272
+ An adult word beside an `Under N` at 18 or above reads `adult`: Adult is a band with an 18
273
+ floor, and the `Under 40` splits it. Below 18 the youth band stands.
274
+
275
+ An age group is the age on the first of January, so a title that admits the dancer who is
276
+ `N` (`7 & Under`, `7/U`, `7 Years`) is the `Under N+1` group.
277
+
278
+ A value the reader cannot stand behind is returned, not thrown. An `Age` is `Validated` (above):
279
+ an age group under `U3` admits nobody old enough to dance, and a year of birth outside 1901 to
280
+ 2099 is not one, so the placeholders `1000` and `1900` are refused. An invalid `Age` has no
281
+ birth years and rules nothing out.
188
282
 
189
283
  ```ts
190
284
  Age.from("U2", { year: 2026 }).isValid(); // false
191
285
  Age.from("U2", { year: 2026 }).invalidReason(); // "below-youngest-band"
192
286
  Age.fromYOB(2222).invalidReason(); // "year-out-of-range"
193
287
  Age.from("All", { year: 2026 }).invalidReason(); // "not-a-band"
194
- Age.from("", { year: 2026 }).isValid(); // true: empty, not invalid
288
+ Age.from("", { year: 2026 }).isEmpty(); // true: empty, not refused
289
+ Age.from("", { year: 2026 }).invalidReason(); // "empty"
195
290
  ```
196
291
 
197
292
  A team's age is the average of its dancers' ages, rounded to two decimals. `averageAge`
package/dist/age.js CHANGED
@@ -1,3 +1,112 @@
1
+ import {
2
+ validity
3
+ } from "./chunks/place-q9kwsmzd.js";
4
+
5
+ var LEAD_ID = /^\s*(?:comp(?:etition)?\.?\s*)?\d+[a-z]*(?:[/-]\d*[a-z]*)*\s*[-:.]?\s*/i;
6
+ var PAREN = /\(([^)]*)\)/g;
7
+ var AND_OVER = /(?<![a-z0-9])(\d{1,2})\s*(?:years?\s*)?(?:&|\+|\band\b)\s*(?:over|older|up|o)(?![a-z])/i;
8
+ var OVER_WORD = /(?:\bover\s*|\bos\s+cionn\s*|(?<![a-z0-9])o\.?\s*)(\d{1,2})(?![a-z0-9])/i;
9
+ var OVER_GLUED = /(?<![a-z0-9])(\d{1,2})\s*o(?![a-z'’])/i;
10
+ var OVER_PLUS = /(?<![a-z0-9])(\d{1,2})\s*\+/;
11
+ var UNDER = /(?:\bunder\s*|\bfaoi\s*|(?<![a-z0-9])u\.?\s*)(\d{1,2})(?![a-z0-9])/i;
12
+ var AND_UNDER = /(?<![a-z0-9])(\d{1,2})\s*(?:years?\s*)?(?:&|\/|\band\b)\s*u(?:nder)?(?![a-z])(?!\s*\d)/i;
13
+ var AND_UNDER_RANGE = /(?<![a-z0-9])(\d{1,2})\s*(?:&|\band\b)\s*under\s*(\d{1,2})(?![a-z0-9])/i;
14
+ var AND_RANGE = /(?<![a-z0-9])(?<!\b(?:under|und|over|faoi|cionn|u|o)\.?\s*)(?<!\d\s*,\s*)(?<!\(\s*)(\d{1,2})\s*(?:&|\band\b)\s*(\d{1,2})(?![a-z0-9])(?!\s*-?\s*hand)(?!\s*(?:&|\+|\/|,|\)|\band\b|\bor\b))/i;
15
+ var YEARS = /(?<![a-z0-9])(?<!\d\s*(?:&|\/|\band\b|[-\u0096–—]|\bto\b)\s*)(\d{1,2})\s*(?:years?|yrs?)(?![a-z0-9])(?!\s*(?:&|\+|\/|\band\b|\bor\b|old))/i;
16
+ var RANGE_DASH = String.raw`(?:\u00c2?[-\u0096–—]|\bto\b)`;
17
+ var RANGE = new RegExp(String.raw`(?<![0-9])(?<![0-9][a-z]*)(?<!(?:^|[^a-z0-9])[a-z])(?:u\s*)?(\d{1,2})\s*${RANGE_DASH}\s*(\d{1,2})(?![a-z0-9])`, "i");
18
+ var LEAD_RANGE = new RegExp(String.raw`^\s*([1-9]\d?)\s*${RANGE_DASH}\s*([1-9]\d?)(?![a-z0-9])(?!\s*[/&])`, "i");
19
+ var ADULT_CODE = /^\s*a(?:\d{1,2}(?:u|o|&o))?\s*$/i;
20
+ var ADULT = /(?<!non[\s-])(?<!-)\b(?:adults?|advanced\s+adults?|masters?)\b/i;
21
+ var SENIOR = /(?<!-)\bseniors?\b/i;
22
+ var TITLE_RANGE_MIN = 3;
23
+ var TITLE_RANGE_MAX = 40;
24
+ var NONE = Object.freeze({ token: "", span: null });
25
+ function at(title, offset, start, end, token) {
26
+ const a = offset + start;
27
+ const b = offset + end;
28
+ return { token, span: { start: a, end: b, text: title.slice(a, b) } };
29
+ }
30
+ function inFence(a, b) {
31
+ const lo = Number(a);
32
+ const hi = Number(b);
33
+ return lo >= TITLE_RANGE_MIN && hi <= TITLE_RANGE_MAX && lo < hi;
34
+ }
35
+ function readAnchored(title, s, offset, aside = false) {
36
+ for (const re of [AND_OVER, OVER_WORD, OVER_GLUED]) {
37
+ const m = re.exec(s);
38
+ if (m?.[1] !== undefined)
39
+ return at(title, offset, m.index, m.index + m[0].length, `o${Number(m[1])}`);
40
+ }
41
+ const u = AND_UNDER_RANGE.exec(s);
42
+ if (u?.[1] !== undefined && u[2] !== undefined && inFence(u[1], u[2]))
43
+ return at(title, offset, u.index, u.index + u[0].length, `${Number(u[1])}-${Number(u[2])}`);
44
+ const j = aside ? null : AND_RANGE.exec(s);
45
+ if (j?.[1] !== undefined && j[2] !== undefined && inFence(j[1], j[2]))
46
+ return at(title, offset, j.index, j.index + j[0].length, `${Number(j[1])}-${Number(j[2])}`);
47
+ const l = LEAD_RANGE.exec(s);
48
+ if (l?.[1] !== undefined && l[2] !== undefined && inFence(l[1], l[2])) {
49
+ const lead = l[0].length - l[0].trimStart().length;
50
+ return at(title, offset, lead, l[0].length, `${Number(l[1])}-${Number(l[2])}`);
51
+ }
52
+ const stripped = s.replace(LEAD_ID, "");
53
+ const shift = s.length - stripped.length;
54
+ const r = RANGE.exec(stripped);
55
+ if (r?.[1] !== undefined && r[2] !== undefined && inFence(r[1], r[2]))
56
+ return at(title, offset + shift, r.index, r.index + r[0].length, `${Number(r[1])}-${Number(r[2])}`);
57
+ const au = AND_UNDER.exec(s);
58
+ if (au?.[1] !== undefined)
59
+ return at(title, offset, au.index, au.index + au[0].length, `u${Number(au[1]) + 1}`);
60
+ const m = UNDER.exec(s);
61
+ if (m?.[1] !== undefined)
62
+ return at(title, offset, m.index, m.index + m[0].length, `u${Number(m[1])}`);
63
+ const y = YEARS.exec(s);
64
+ if (y?.[1] !== undefined)
65
+ return at(title, offset, y.index, y.index + y[0].length, `u${Number(y[1]) + 1}`);
66
+ const p = OVER_PLUS.exec(s);
67
+ if (p?.[1] !== undefined)
68
+ return at(title, offset, p.index, p.index + p[0].length, `o${Number(p[1])}`);
69
+ const a = ADULT.exec(s);
70
+ if (a !== null)
71
+ return at(title, offset, a.index, a.index + a[0].length, "adult");
72
+ const n = SENIOR.exec(s);
73
+ if (n !== null)
74
+ return at(title, offset, n.index, n.index + n[0].length, "senior");
75
+ return NONE;
76
+ }
77
+ function ageTitleToken(title) {
78
+ const s = title.trim();
79
+ if (s === "")
80
+ return NONE;
81
+ const lead = title.length - title.trimStart().length;
82
+ PAREN.lastIndex = 0;
83
+ let adultAside = null;
84
+ for (const m of s.matchAll(PAREN)) {
85
+ const inner = m[1] ?? "";
86
+ const from = lead + (m.index ?? 0) + 1;
87
+ const i = m.index ?? 0;
88
+ const tail = i + m[0].length === s.length && !/[a-z0-9]/i.test(s[i - 1] ?? "");
89
+ if (tail && ADULT_CODE.test(inner)) {
90
+ const a = inner.length - inner.trimStart().length;
91
+ adultAside = at(title, from, a, inner.trimEnd().length, "adult");
92
+ continue;
93
+ }
94
+ const t = readAnchored(title, inner, from, true);
95
+ if (t.token !== "")
96
+ return adultOverUnder(title, s, lead, t);
97
+ }
98
+ const words = readAnchored(title, s, lead);
99
+ return words.token === "" && adultAside !== null ? adultAside : adultOverUnder(title, s, lead, words);
100
+ }
101
+ var ADULT_UNDER_MIN = 18;
102
+ function adultOverUnder(title, s, lead, t) {
103
+ const n = /^u(\d+)$/.exec(t.token)?.[1];
104
+ if (n === undefined || Number(n) < ADULT_UNDER_MIN)
105
+ return t;
106
+ const a = ADULT.exec(s);
107
+ return a === null ? t : at(title, lead, a.index, a.index + a[0].length, "adult");
108
+ }
109
+
1
110
  var YOUNGEST_FLOOR = 0;
2
111
  var ADULT_FLOOR = 18;
3
112
  var MAJORS_FLOOR_YEARS = 3;
@@ -7,13 +116,14 @@ var YOUNG_FLOOR = Object.freeze({ hard: 2, soft: 4 });
7
116
  var MIN_MAJOR_AGE = 4;
8
117
  var MIN_FEIS_AGE = 3;
9
118
  var YOUNGEST_BAND_TOP = MAJORS_FLOOR_YEARS;
10
- var YOB_RANGE = Object.freeze({ first: 1900, last: 2099 });
119
+ var YOB_RANGE = Object.freeze({ first: 1901, last: 2099 });
11
120
  var DANCE_UP_YEARS = 1;
12
121
  var ageOnJanuary1 = (year, born) => year - born - 1;
13
122
  var yearsSinceBirth = (year, born) => year - born;
14
- var UNDER = /^(?:u|under\s*)(\d+)$/i;
123
+ var UNDER2 = /^(?:u|under\s*)(\d+)$/i;
15
124
  var OVER = /^(?:o|over\s*)(\d+)$/i;
16
- var RANGE = /^u?(\d+)\s*[-–]\s*(\d+)$/i;
125
+ var AND_OVER2 = /^(\d+)\s*(?:&|and\b)\s*(?:over|o)$/i;
126
+ var RANGE2 = /^u?(\d+)\s*[-–]\s*(\d+)$/i;
17
127
  var ADULT_WORD = /^(?:adults?|ad|advanced|masters?)$/i;
18
128
  var SENIOR_WORD = /^seniors?$/i;
19
129
  var NO_LADDER = Object.freeze({ caps: [], overs: [], ranges: [] });
@@ -40,29 +150,36 @@ function readRange(a, b, ladder) {
40
150
  clauses: [],
41
151
  parts: [a, b]
42
152
  };
43
- const overlapping = ladder.ranges.includes(a) || ladder.ranges.includes(b + 1);
44
- const bottom = !ladder.caps.some((c) => c <= a);
45
- const atAdultFloor = bottom && a >= ADULT_FLOOR;
46
- const youngest = bottom && !atAdultFloor;
47
- const onRung = ladder.caps.includes(a);
48
- const floor = overlapping || atAdultFloor || onRung ? a : Math.max(YOUNGEST_FLOOR, a - 1);
49
- const clauses = [overlapping ? "range-overlapping" : "range-inclusive"];
50
- if (youngest)
51
- clauses.push("youngest-absorbs");
52
- else if (atAdultFloor)
53
- clauses.push("range-adult-floor");
54
- else if (!overlapping)
55
- clauses.push(onRung ? "range-floor-on-rung" : "range-floor-widened");
153
+ if (ladder.ranges.some(([c, d]) => (c !== a || d !== b) && c < b && a < d))
154
+ return {
155
+ kind: "range",
156
+ interval: null,
157
+ refusal: "overlapping-ranges",
158
+ clauses: ["range-overlapping"],
159
+ parts: [a, b]
160
+ };
161
+ const below = maxWhere(ladder.caps, (c) => c < b);
162
+ const adultFloor = below === undefined && ladder.adult === true && b > ADULT_FLOOR;
163
+ const meetsOver = ladder.overs.includes(b + 1) && !ladder.caps.some((c) => c > b);
164
+ const clauses = [
165
+ "range-as-under",
166
+ below !== undefined ? "under-floored-on-rung" : adultFloor ? "range-adult-floor" : "youngest-absorbs"
167
+ ];
168
+ if (meetsOver)
169
+ clauses.push("range-top-meets-over");
56
170
  return {
57
171
  kind: "range",
58
- interval: { lo: youngest ? YOUNGEST_FLOOR : floor, hi: overlapping ? b : b + 1 },
172
+ interval: {
173
+ lo: below ?? (adultFloor ? ADULT_FLOOR : YOUNGEST_FLOOR),
174
+ hi: meetsOver ? b + 1 : b
175
+ },
59
176
  refusal: null,
60
177
  clauses,
61
178
  parts: [a, b]
62
179
  };
63
180
  }
64
181
  function read(token, ladder, top) {
65
- const r = RANGE.exec(token);
182
+ const r = RANGE2.exec(token);
66
183
  if (r !== null)
67
184
  return readRange(Number(r[1]), Number(r[2]), ladder);
68
185
  if (ADULT_WORD.test(token))
@@ -83,7 +200,7 @@ function read(token, ladder, top) {
83
200
  parts: []
84
201
  };
85
202
  }
86
- const u = UNDER.exec(token);
203
+ const u = UNDER2.exec(token);
87
204
  if (u !== null) {
88
205
  const n = Number(u[1]);
89
206
  const below = maxWhere(ladder.caps, (c) => c < n);
@@ -95,7 +212,7 @@ function read(token, ladder, top) {
95
212
  parts: [n]
96
213
  };
97
214
  }
98
- const o = OVER.exec(token);
215
+ const o = OVER.exec(token) ?? AND_OVER2.exec(token);
99
216
  if (o !== null) {
100
217
  const n = Number(o[1]);
101
218
  const above = top ? undefined : minWhere(ladder.overs, (c) => c > n);
@@ -169,13 +286,11 @@ function fromPrint(print, context = {}) {
169
286
  parts: reading.parts,
170
287
  length: ag2.length,
171
288
  refusal: reading.refusal,
172
- isValid: () => reading.refusal === null,
173
- invalidReason: () => reading.refusal,
289
+ ...validity(arm === "empty", reading.refusal),
174
290
  source,
175
291
  clauses: reading.clauses,
176
292
  ladder,
177
293
  year,
178
- isEmpty: () => arm === "empty",
179
294
  isAge: () => iv !== null,
180
295
  isExact: () => {
181
296
  if (iv === null || year === null)
@@ -192,7 +307,7 @@ function fromPrint(print, context = {}) {
192
307
  throw new Error(`Age.from(${JSON.stringify(print)}): birthYears needs a competition year`);
193
308
  return birthYearsOfInterval(iv, at);
194
309
  },
195
- floorObserved: () => reading.kind !== "under" || iv === null ? null : reading.clauses.includes("under-floored-on-rung")
310
+ floorObserved: () => reading.kind !== "under" && reading.kind !== "range" || iv === null ? null : reading.clauses.includes("under-floored-on-rung") || reading.clauses.includes("range-adult-floor")
196
311
  });
197
312
  }
198
313
  function fromYOB(from, to = from, context = {}) {
@@ -221,13 +336,11 @@ function fromYOB(from, to = from, context = {}) {
221
336
  parts: from !== null && from === to ? [from] : [from, to].filter((y) => y !== null),
222
337
  length: ag2.length,
223
338
  refusal,
224
- isValid: () => valid,
225
- invalidReason: () => refusal,
339
+ ...validity(false, refusal),
226
340
  source: valid ? "printed-yob" : "open",
227
341
  clauses: [],
228
342
  ladder: NO_LADDER,
229
343
  year: null,
230
- isEmpty: () => false,
231
344
  isAge: () => valid,
232
345
  isExact: () => exact,
233
346
  isAndOver: () => valid && from === null,
@@ -237,21 +350,24 @@ function fromYOB(from, to = from, context = {}) {
237
350
  floorObserved: () => null
238
351
  });
239
352
  }
240
- var Age = Object.freeze({ from: fromPrint, fromYOB });
353
+ function fromTitle(title, context = {}) {
354
+ const t = ageTitleToken(title);
355
+ return Object.freeze({ age: fromPrint(t.token, context), token: t.token, span: t.span });
356
+ }
357
+ var Age = Object.freeze({ from: fromPrint, fromYOB, fromTitle });
241
358
  function sourceOf(r) {
242
359
  const iv = r.interval;
243
360
  if (iv === null)
244
361
  return "open";
245
362
  switch (r.kind) {
246
363
  case "range":
247
- return "printed-range";
364
+ case "under":
365
+ return iv.lo > YOUNGEST_FLOOR ? "sibling-narrowed" : "printed-band";
248
366
  case "over":
249
367
  return iv.hi === Number.POSITIVE_INFINITY ? "printed-band" : "sibling-narrowed";
250
368
  case "adult":
251
369
  case "senior":
252
370
  return "printed-band";
253
- case "under":
254
- return iv.lo > YOUNGEST_FLOOR ? "sibling-narrowed" : "printed-band";
255
371
  default:
256
372
  return "open";
257
373
  }
@@ -435,6 +551,8 @@ export {
435
551
  MAJORS_FLOOR_YEARS,
436
552
  MIN_FEIS_AGE,
437
553
  MIN_MAJOR_AGE,
554
+ TITLE_RANGE_MAX,
555
+ TITLE_RANGE_MIN,
438
556
  UNDER_CAP,
439
557
  UNDER_CAP_MAX_TOP,
440
558
  YOB_RANGE,
@@ -443,6 +561,7 @@ export {
443
561
  YOUNG_FLOOR,
444
562
  ageOnJanuary1,
445
563
  ageRelation,
564
+ ageTitleToken,
446
565
  averageAge,
447
566
  birthYearsDisjoint,
448
567
  birthYearsMeet,
package/dist/ceili.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  ceiliById2,
5
5
  ceili2,
6
6
  ceiliRelation2
7
- } from "./chunks/place-0ehc2q69.js";
7
+ } from "./chunks/place-kk48pfja.js";
8
8
  import"./chunks/place-htxgptyv.js";
9
9
  export {
10
10
  CEILI_NODES2 as CEILI_NODES,