chronolizer 0.4.0 → 0.4.1

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.
@@ -1,24 +1,41 @@
1
1
  import { Correction, NaturalCorrectionCandidate } from "../language/model.mjs";
2
2
  import { naturalWords } from "./text.mjs";
3
- import { Option, String } from "effect";
4
3
  //#region src/natural/correction.ts
5
- const isProtectedValue = (word) => Option.isSome(String.match(/^\d+$/u)(word)) || Option.isSome(String.match(/^\d{4}-\d{2}-\d{2}$/u)(word));
6
- const damerauLevenshteinDistance = (left, right) => {
7
- const fallback = left.length + right.length;
8
- let previousPrevious;
9
- let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
4
+ const isProtectedValue = (word) => /^(?:\d+|\d{4}-\d{2}-\d{2})$/u.test(word);
5
+ const damerauLevenshteinDistance = (left, right, maximum = Math.max(left.length, right.length), workspace = [
6
+ [],
7
+ [],
8
+ []
9
+ ]) => {
10
+ const outside = maximum + 1;
11
+ if (Math.abs(left.length - right.length) > maximum) return outside;
12
+ const width = right.length + 1;
13
+ let [previousPrevious, previous, current] = workspace;
14
+ for (const row of workspace) {
15
+ row.length = width;
16
+ row.fill(outside);
17
+ }
18
+ for (let column = 0; column <= Math.min(right.length, maximum); column += 1) previous[column] = column;
10
19
  for (let row = 1; row <= left.length; row += 1) {
11
- const current = [row];
12
- for (let column = 1; column <= right.length; column += 1) {
13
- const substitution = left.charAt(row - 1) === right.charAt(column - 1) ? 0 : 1;
14
- const distance = Math.min((previous[column] ?? fallback) + 1, (current[column - 1] ?? fallback) + 1, (previous[column - 1] ?? fallback) + substitution);
15
- const transposed = previousPrevious !== void 0 && row > 1 && column > 1 && left.charAt(row - 1) === right.charAt(column - 2) && left.charAt(row - 2) === right.charAt(column - 1) ? (previousPrevious[column - 2] ?? fallback) + 1 : fallback;
16
- current.push(Math.min(distance, transposed));
20
+ current.fill(outside);
21
+ if (row <= maximum) current[0] = row;
22
+ const firstColumn = Math.max(1, row - maximum);
23
+ const lastColumn = Math.min(right.length, row + maximum);
24
+ let rowMinimum = row <= maximum ? row : outside;
25
+ for (let column = firstColumn; column <= lastColumn; column += 1) {
26
+ const substitution = left.charCodeAt(row - 1) === right.charCodeAt(column - 1) ? 0 : 1;
27
+ let distance = Math.min((previous[column] ?? outside) + 1, (current[column - 1] ?? outside) + 1, (previous[column - 1] ?? outside) + substitution);
28
+ if (row > 1 && column > 1 && left.charCodeAt(row - 1) === right.charCodeAt(column - 2) && left.charCodeAt(row - 2) === right.charCodeAt(column - 1)) distance = Math.min(distance, (previousPrevious[column - 2] ?? outside) + 1);
29
+ current[column] = distance;
30
+ rowMinimum = Math.min(rowMinimum, distance);
17
31
  }
32
+ if (rowMinimum > maximum) return outside;
33
+ const reusable = previousPrevious;
18
34
  previousPrevious = previous;
19
35
  previous = current;
36
+ current = reusable;
20
37
  }
21
- return previous[right.length] ?? fallback;
38
+ return previous[right.length] ?? outside;
22
39
  };
23
40
  const segmentedReplacements = (word, vocabulary) => {
24
41
  const segmentations = Array.from({ length: word.length + 1 }, () => []);
@@ -43,34 +60,60 @@ const segmentedReplacements = (word, vocabulary) => {
43
60
  distance: parts.length - 1
44
61
  }));
45
62
  };
46
- const replacementsFor = (word, vocabulary, segmentationVocabulary) => {
47
- if (vocabulary.includes(word) || isProtectedValue(word)) return [{
63
+ const vocabularySets = /* @__PURE__ */ new WeakMap();
64
+ const vocabularySet = (vocabulary) => {
65
+ const cached = vocabularySets.get(vocabulary);
66
+ if (cached !== void 0) return cached;
67
+ const words = new Set(vocabulary);
68
+ vocabularySets.set(vocabulary, words);
69
+ return words;
70
+ };
71
+ const replacementsFor = (word, vocabulary, segmentationVocabulary, workspace) => {
72
+ if (vocabulary.has(word) || isProtectedValue(word)) return [{
48
73
  word,
49
74
  distance: 0
50
75
  }];
51
76
  const segmented = segmentedReplacements(word, segmentationVocabulary);
52
77
  if (word.length <= 3) return segmented;
53
78
  const maximum = word.length >= 6 ? 2 : 1;
54
- const fuzzy = vocabulary.filter((candidate) => Math.abs(candidate.length - word.length) <= maximum).map((candidate) => ({
55
- word: candidate,
56
- distance: damerauLevenshteinDistance(word, candidate)
57
- })).filter((candidate) => candidate.distance <= maximum);
58
- const matches = [...segmented, ...fuzzy];
59
- if (matches.length === 0) return [];
60
- const minimum = Math.min(...matches.map((candidate) => candidate.distance));
61
- return matches.filter((candidate) => candidate.distance === minimum).slice(0, 4);
79
+ let minimum = Number.POSITIVE_INFINITY;
80
+ const matches = [];
81
+ const addMatch = (replacement, distance) => {
82
+ if (distance > minimum) return;
83
+ if (distance < minimum) {
84
+ minimum = distance;
85
+ matches.length = 0;
86
+ }
87
+ if (matches.length < 4) matches.push({
88
+ word: replacement,
89
+ distance
90
+ });
91
+ };
92
+ for (const replacement of segmented) addMatch(replacement.word, replacement.distance);
93
+ for (const candidate of vocabulary) {
94
+ if (Math.abs(candidate.length - word.length) > maximum) continue;
95
+ const distance = damerauLevenshteinDistance(word, candidate, maximum, workspace);
96
+ if (distance <= maximum) addMatch(candidate, distance);
97
+ }
98
+ return matches;
62
99
  };
63
100
  const emptySegmentationVocabulary = /* @__PURE__ */ new Set();
64
101
  const correctWhitespaceSeparatedText = (input, vocabulary, segmentationVocabulary = emptySegmentationVocabulary) => {
65
102
  const words = naturalWords(input);
103
+ const wordsInVocabulary = vocabularySet(vocabulary);
104
+ const workspace = [
105
+ [],
106
+ [],
107
+ []
108
+ ];
66
109
  let partials = [{
67
- words: [],
110
+ text: "",
68
111
  corrections: [],
69
112
  cost: 0,
70
113
  offset: 0
71
114
  }];
72
115
  for (const word of words) {
73
- const replacements = replacementsFor(word, vocabulary, segmentationVocabulary);
116
+ const replacements = replacementsFor(word, wordsInVocabulary, segmentationVocabulary, workspace);
74
117
  if (replacements.length === 0) return [];
75
118
  const next = [];
76
119
  for (const partial of partials) for (const replacement of replacements) {
@@ -81,7 +124,7 @@ const correctWhitespaceSeparatedText = (input, vocabulary, segmentationVocabular
81
124
  offset: partial.offset
82
125
  })];
83
126
  next.push({
84
- words: [...partial.words, replacement.word],
127
+ text: partial.text.length === 0 ? replacement.word : `${partial.text} ${replacement.word}`,
85
128
  corrections: correction,
86
129
  cost: partial.cost + replacement.distance,
87
130
  offset: partial.offset + word.length + 1
@@ -90,7 +133,7 @@ const correctWhitespaceSeparatedText = (input, vocabulary, segmentationVocabular
90
133
  partials = next.slice(0, 32);
91
134
  }
92
135
  return partials.filter((partial) => partial.corrections.length > 0).map((partial) => NaturalCorrectionCandidate.make({
93
- text: partial.words.join(" "),
136
+ text: partial.text,
94
137
  corrections: partial.corrections,
95
138
  cost: partial.cost
96
139
  }));
@@ -5,7 +5,7 @@ import { LanguageRegistry } from "../language/registry.mjs";
5
5
  import { applyFuturePolicy } from "./policy.mjs";
6
6
  import { Array, Effect, Order, Result } from "effect";
7
7
  //#region src/natural/parse.ts
8
- const distinctCandidates = (candidates) => Array.dedupeWith(candidates, (left, right) => rangeKey(left.range) === rangeKey(right.range));
8
+ const distinctCandidates = (candidates) => candidates.length === 1 ? candidates : Array.dedupeWith(candidates, (left, right) => rangeKey(left.range) === rangeKey(right.range));
9
9
  const parseQuality = (hasAlternatives, hasCorrections) => {
10
10
  if (hasAlternatives) return "ambiguous";
11
11
  if (hasCorrections) return "corrected";
@@ -30,7 +30,15 @@ const suggestNatural = Effect.fn("chronolizer.suggestNatural")(function* (input,
30
30
  text: candidate.canonical,
31
31
  range: candidate.range
32
32
  })));
33
- const distinct = (suggestions) => Array$1.dedupeWith(suggestions, (left, right) => rangeKey(left.range) === rangeKey(right.range));
33
+ const distinct = (suggestions) => {
34
+ const keys = /* @__PURE__ */ new Set();
35
+ return suggestions.filter((suggestion) => {
36
+ const key = rangeKey(suggestion.range);
37
+ if (keys.has(key)) return false;
38
+ keys.add(key);
39
+ return true;
40
+ });
41
+ };
34
42
  const suggested = distinct(suggestionsFrom([...language.suggest(normalized, limit * 2), normalized]));
35
43
  if (suggested.length > 0) return Array$1.take(suggested, limit);
36
44
  const completionVocabulary = Array$1.dedupe([...Array$1.flatMap(language.suggest("", 100), naturalWords), ...language.vocabulary]);
@@ -1,40 +1,44 @@
1
1
  import { damerauLevenshteinDistance } from "./correction.mjs";
2
- import { Array as Array$1, Option, String } from "effect";
2
+ import { Option, String } from "effect";
3
3
  //#region src/natural/suggestion.ts
4
- const fuzzyPrefixDistance = (input, target) => {
5
- const lengths = Array$1.dedupe([
4
+ const completionCost = (input, target, targetStart, targetEnd, workspace) => {
5
+ const targetLength = targetEnd - targetStart;
6
+ if (input.length <= targetLength && target.startsWith(input, targetStart)) return 0;
7
+ if (input.length < 3 || input.charCodeAt(0) !== target.charCodeAt(targetStart)) return void 0;
8
+ const maximum = input.length >= 5 ? 2 : 1;
9
+ let minimum = maximum + 1;
10
+ let previousLength = -1;
11
+ for (const length of [
6
12
  Math.max(1, input.length - 1),
7
13
  input.length,
8
- Math.min(target.length, input.length + 1)
9
- ]);
10
- return Math.min(...lengths.map((length) => damerauLevenshteinDistance(input, target.slice(0, length))));
11
- };
12
- const completionCost = (input, target) => {
13
- if (input === target || target.startsWith(input)) return 0;
14
- if (input.length < 3 || input[0] !== target[0]) return void 0;
15
- const maximum = input.length >= 5 ? 2 : 1;
16
- const distance = fuzzyPrefixDistance(input, target);
17
- return distance <= maximum ? distance : void 0;
14
+ Math.min(targetLength, input.length + 1)
15
+ ]) {
16
+ if (length === previousLength) continue;
17
+ previousLength = length;
18
+ minimum = Math.min(minimum, damerauLevenshteinDistance(input, target.slice(targetStart, targetStart + length), maximum, workspace));
19
+ }
20
+ return minimum <= maximum ? minimum : void 0;
18
21
  };
19
- const phraseScore = (input, phrase) => {
22
+ const phraseScore = (input, inputWords, phrase, workspace) => {
20
23
  if (input === phrase) return 0;
21
24
  if (phrase.startsWith(input)) return 1;
22
- const inputWords = input.split(" ");
23
- const phraseWords = phrase.split(" ");
24
- if (inputWords.length > phraseWords.length) return void 0;
25
25
  let cost = 0;
26
26
  let addedWords = 0;
27
- let phraseIndex = 0;
27
+ let phraseStart = 0;
28
28
  for (const inputWord of inputWords) {
29
- let wordCost = completionCost(inputWord, phraseWords[phraseIndex] ?? "");
29
+ let phraseEnd = phrase.indexOf(" ", phraseStart);
30
+ if (phraseEnd === -1) phraseEnd = phrase.length;
31
+ let wordCost = completionCost(inputWord, phrase, phraseStart, phraseEnd, workspace);
30
32
  while (wordCost === void 0 && addedWords < 3) {
31
- phraseIndex += 1;
33
+ phraseStart = phraseEnd + 1;
34
+ phraseEnd = phrase.indexOf(" ", phraseStart);
35
+ if (phraseEnd === -1) phraseEnd = phrase.length;
32
36
  addedWords += 1;
33
- wordCost = completionCost(inputWord, phraseWords[phraseIndex] ?? "");
37
+ wordCost = completionCost(inputWord, phrase, phraseStart, phraseEnd, workspace);
34
38
  }
35
39
  if (wordCost === void 0) return void 0;
36
40
  cost += wordCost;
37
- phraseIndex += 1;
41
+ phraseStart = phraseEnd + 1;
38
42
  }
39
43
  return 2 + cost + addedWords;
40
44
  };
@@ -70,16 +74,30 @@ const naturalCount = (input) => {
70
74
  };
71
75
  const completeNaturalPhrases = (input, phrases, limit) => {
72
76
  const ranked = [];
73
- for (const [index, phrase] of Array$1.dedupe(phrases).entries()) {
74
- const score = phraseScore(input, phrase);
77
+ const inputWords = input.split(" ");
78
+ const workspace = [
79
+ [],
80
+ [],
81
+ []
82
+ ];
83
+ let index = 0;
84
+ for (const phrase of new Set(phrases)) {
85
+ const score = phraseScore(input, inputWords, phrase, workspace);
75
86
  if (score !== void 0) ranked.push({
76
87
  phrase,
77
88
  score,
78
89
  index
79
90
  });
91
+ index += 1;
92
+ }
93
+ ranked.sort((left, right) => left.score - right.score || left.index - right.index);
94
+ const maximumScore = (ranked[0]?.score ?? 2) <= 1 ? 1 : Number.POSITIVE_INFINITY;
95
+ const matches = [];
96
+ for (const entry of ranked) {
97
+ if (entry.score > maximumScore || matches.length >= limit) break;
98
+ matches.push(entry.phrase);
80
99
  }
81
- const sorted = ranked.sort((left, right) => left.score - right.score || left.index - right.index);
82
- return (sorted.some((entry) => entry.score <= 1) ? sorted.filter((entry) => entry.score <= 1) : sorted).slice(0, limit).map((entry) => entry.phrase);
100
+ return matches;
83
101
  };
84
102
  //#endregion
85
103
  export { completeNaturalPhrases, fixedCalendarPeriodPhrases, naturalCount, prefixNaturalPhrases };
@@ -6,8 +6,7 @@ const shiftDateTime = (value, amount, unit) => Match.value(unit).pipe(Match.when
6
6
  const startOfDateTime = (value, unit) => {
7
7
  if (unit === "quarter") {
8
8
  const month = DateTime.getPart(value, "month");
9
- const quarterMonth = Math.floor((month - 1) / 3) * 3 + 1;
10
- return DateTime.startOf(DateTime.setParts(value, { month: quarterMonth }), "month");
9
+ return DateTime.startOf(DateTime.subtract(value, { months: (month - 1) % 3 }), "month");
11
10
  }
12
11
  return DateTime.startOf(value, unit, { weekStartsOn: 1 });
13
12
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chronolizer",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Bidirectional natural-language date ranges for Effect.",
5
5
  "homepage": "https://github.com/tobimori/chronolizer#readme",
6
6
  "bugs": "https://github.com/tobimori/chronolizer/issues",
@@ -54,6 +54,7 @@
54
54
  }
55
55
  },
56
56
  "scripts": {
57
+ "benchmark": "pnpm build && node tools/benchmark.mjs",
57
58
  "build": "vp pack",
58
59
  "check:bundle-size": "node tools/check-bundle-size.mjs",
59
60
  "check:tree-shaking": "node tools/check-tree-shaking.mjs",