chaffjs 0.27.0 → 0.28.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 (72) hide show
  1. package/README.md +2 -1
  2. package/dist/cli-text.d.ts.map +1 -1
  3. package/dist/cli-text.js +6 -4
  4. package/dist/cli-text.js.map +1 -1
  5. package/dist/derived/number-word-counts.d.ts +10 -0
  6. package/dist/derived/number-word-counts.d.ts.map +1 -0
  7. package/dist/derived/number-word-counts.js +21 -0
  8. package/dist/derived/number-word-counts.js.map +1 -0
  9. package/dist/detectors/amount-scale.d.ts +3 -0
  10. package/dist/detectors/amount-scale.d.ts.map +1 -0
  11. package/dist/detectors/amount-scale.js +50 -0
  12. package/dist/detectors/amount-scale.js.map +1 -0
  13. package/dist/detectors/change-rate.d.ts.map +1 -1
  14. package/dist/detectors/change-rate.js +42 -8
  15. package/dist/detectors/change-rate.js.map +1 -1
  16. package/dist/detectors/currency-notation.d.ts +3 -1
  17. package/dist/detectors/currency-notation.d.ts.map +1 -1
  18. package/dist/detectors/currency-notation.js +2 -2
  19. package/dist/detectors/currency-notation.js.map +1 -1
  20. package/dist/detectors/derived-numbers.d.ts.map +1 -1
  21. package/dist/detectors/derived-numbers.js +9 -1
  22. package/dist/detectors/derived-numbers.js.map +1 -1
  23. package/dist/detectors/fact-consistency.d.ts.map +1 -1
  24. package/dist/detectors/fact-consistency.js +14 -1
  25. package/dist/detectors/fact-consistency.js.map +1 -1
  26. package/dist/detectors/name-variant.d.ts.map +1 -1
  27. package/dist/detectors/name-variant.js +18 -10
  28. package/dist/detectors/name-variant.js.map +1 -1
  29. package/dist/detectors/registry/amount-scale.d.ts +3 -0
  30. package/dist/detectors/registry/amount-scale.d.ts.map +1 -0
  31. package/dist/detectors/registry/amount-scale.js +3 -0
  32. package/dist/detectors/registry/amount-scale.js.map +1 -0
  33. package/dist/facts/counted-facts.d.ts +16 -0
  34. package/dist/facts/counted-facts.d.ts.map +1 -0
  35. package/dist/facts/counted-facts.js +45 -0
  36. package/dist/facts/counted-facts.js.map +1 -0
  37. package/dist/name-variants.d.ts +19 -5
  38. package/dist/name-variants.d.ts.map +1 -1
  39. package/dist/name-variants.js +60 -6
  40. package/dist/name-variants.js.map +1 -1
  41. package/dist/structure/amount-scale.d.ts +29 -0
  42. package/dist/structure/amount-scale.d.ts.map +1 -0
  43. package/dist/structure/amount-scale.js +70 -0
  44. package/dist/structure/amount-scale.js.map +1 -0
  45. package/dist/structure/change-rate.d.ts +19 -1
  46. package/dist/structure/change-rate.d.ts.map +1 -1
  47. package/dist/structure/change-rate.js +115 -15
  48. package/dist/structure/change-rate.js.map +1 -1
  49. package/dist/structure/subject-change.d.ts +8 -0
  50. package/dist/structure/subject-change.d.ts.map +1 -0
  51. package/dist/structure/subject-change.js +31 -0
  52. package/dist/structure/subject-change.js.map +1 -0
  53. package/package.json +3 -3
  54. package/rules/amount-scale-consistency.yaml +74 -0
  55. package/rules/change-rate-mismatch.yaml +7 -6
  56. package/rules/elapsed-years-mismatch.yaml +2 -2
  57. package/rules/name-variant.yaml +5 -2
  58. package/rules/summary-fact-mismatch.yaml +2 -2
  59. package/src/cli-text.ts +6 -4
  60. package/src/derived/number-word-counts.ts +28 -0
  61. package/src/detectors/amount-scale.ts +63 -0
  62. package/src/detectors/change-rate.ts +55 -11
  63. package/src/detectors/currency-notation.ts +2 -2
  64. package/src/detectors/derived-numbers.ts +9 -2
  65. package/src/detectors/fact-consistency.ts +17 -1
  66. package/src/detectors/name-variant.ts +14 -4
  67. package/src/detectors/registry/amount-scale.ts +4 -0
  68. package/src/facts/counted-facts.ts +59 -0
  69. package/src/name-variants.ts +106 -11
  70. package/src/structure/amount-scale.ts +97 -0
  71. package/src/structure/change-rate.ts +136 -16
  72. package/src/structure/subject-change.ts +35 -0
@@ -12,6 +12,10 @@ export type Rate = Span & { readonly value: number; readonly decimals: number };
12
12
  export type Direction = Span & { readonly sign: 1 | -1 };
13
13
  /** A word that marks a value: the earlier one after it (「1,000社から」) or before it ("from 1,000"), the later one (「1,200社に」, "to 1,200"). */
14
14
  export type BaseMark = Span & { readonly position: "before" | "after" };
15
+ /** A calendar year written in the sentence (「2025年」, "in 2025"). */
16
+ export type Period = Span & { readonly year: number };
17
+ /** A word that starts another subject or clause; one that joins two values ("… in 2025 and …") is read only before the rate. */
18
+ export type Break = Span & { readonly beforeRateOnly: boolean };
15
19
 
16
20
  export type ChangeText = {
17
21
  readonly sentences: readonly Span[];
@@ -21,6 +25,11 @@ export type ChangeText = {
21
25
  readonly marks: readonly BaseMark[];
22
26
  /** Words that mark the later value ("to", 「に」). */
23
27
  readonly targets: readonly BaseMark[];
28
+ readonly periods: readonly Period[];
29
+ /** Words that start another subject or clause (「利益は」, "and", ";"): a rate after one is not about the values before it. */
30
+ readonly breaks: readonly Break[];
31
+ /** The document's text, which the gaps of a pair are measured in. */
32
+ readonly source: string;
24
33
  };
25
34
 
26
35
  /** How far a direction word may stand from the rate (「20%の増加」「20%増えました」, "an increase of 20%", "up 20%"). */
@@ -32,6 +41,16 @@ const MARK_GAP = 3;
32
41
  * away, the rate is of something else in the sentence (「100店から120店に増え、売上は15%増えた」).
33
42
  */
34
43
  const BASE_REACH = 12;
44
+ /**
45
+ * The two values written together, the earlier then the later or the other way round (「昨年の100万円から今年は150万円」
46
+ * "from $1.0 million last year to $1.5 million"): how far apart they may stand, and how far after them the rate may
47
+ * (「150万円になり、前年比20%」", an increase of 20%"). Measured with a Latin word counting as one character.
48
+ */
49
+ const PAIR_GAP = 16;
50
+ const PAIR_REACH = 16;
51
+ /** How far a year may stand from the value it dates (「2025年の100万円」"$1.0 million in 2025"), a word counting as one. */
52
+ const PERIOD_GAP = 8;
53
+ const LATIN_WORD = /[A-Za-z]+/gu;
35
54
  const PERCENT = 100;
36
55
  const HALF = 0.5;
37
56
  const DECIMAL_BASE = 10;
@@ -40,6 +59,11 @@ const within = (outer: Span, inner: Span): boolean => inner.start >= outer.start
40
59
 
41
60
  const distance = (left: Span, right: Span): number => Math.max(left.start - right.end, right.start - left.end, 0);
42
61
 
62
+ const gapBetween = (left: Span, right: Span): Span => ({ start: left.end, end: right.start });
63
+
64
+ /** The length of a gap, a Latin word counting as one character: "an increase of" is as short as 「前年比」. */
65
+ export const gapLength = (source: string, gap: Span): number => source.slice(gap.start, gap.end).replaceAll(LATIN_WORD, "w").length;
66
+
43
67
  const markOf = (figure: Figure, marks: readonly BaseMark[]): BaseMark | undefined =>
44
68
  marks.find((mark) => {
45
69
  const gap = mark.position === "after" ? mark.start - figure.end : figure.start - mark.end;
@@ -76,28 +100,124 @@ const rateRange = (base: Figure, current: Figure): readonly [number, number] | u
76
100
  return [(currentLow / baseHigh - 1) * PERCENT, (currentHigh / baseLow - 1) * PERCENT];
77
101
  };
78
102
 
79
- const issueIn = (text: ChangeText, sentence: Span): StructureIssue[] => {
80
- const rates = text.rates.filter((rate) => within(sentence, rate));
81
- const figures = text.figures.filter((figure) => within(sentence, figure));
82
- const [rate] = rates;
83
- if (rates.length !== 1 || rate === undefined) return [];
84
- const sign = signNear(
85
- rate,
86
- text.directions.filter((direction) => within(sentence, direction)),
87
- );
88
- const bases = figures.filter((figure) => isBaseFor(figure, rate, text.marks));
103
+ type Pair = { readonly base: Figure; readonly current: Figure };
104
+ /** One sentence's parts, with the rate: the one percentage that has a word of direction beside it. */
105
+ type Reading = ChangeText & { readonly rate: Rate; readonly sign: 1 | -1 };
106
+
107
+ /** A word between the two that starts another subject or clause (「100万円から150万円に増え、利益は20%」). */
108
+ const brokenBetween = (text: Reading, one: Span, other: Span): boolean => {
109
+ const gap = one.end <= other.start ? gapBetween(one, other) : gapBetween(other, one);
110
+ return text.breaks.some((word) => within(gap, word));
111
+ };
112
+
113
+ /** The earlier value marked beside the rate, and the one value in its unit before the rate or marked as reached. */
114
+ const besideRate = (text: Reading): Pair | undefined => {
115
+ const bases = text.figures.filter((figure) => isBaseFor(figure, text.rate, text.marks) && !brokenBetween(text, figure, text.rate));
89
116
  const [base] = bases;
90
- if (sign === undefined || bases.length !== 1 || base === undefined) return [];
91
- const currents = figures.filter((figure) => figure !== base && figure.unit === base.unit && isCurrentFor(figure, rate, text.targets));
117
+ if (bases.length !== 1 || base === undefined) return undefined;
118
+ const currents = text.figures.filter((figure) => figure !== base && figure.unit === base.unit && isCurrentFor(figure, text.rate, text.targets));
92
119
  const [current] = currents;
93
- const range = currents.length === 1 && current !== undefined ? rateRange(base, current) : undefined;
94
- if (range === undefined || current === undefined) return [];
120
+ return currents.length === 1 && current !== undefined ? { base, current } : undefined;
121
+ };
122
+
123
+ const yearsBeside = (text: Reading, figure: Figure, side: "before" | "after", bound: Span): Period[] =>
124
+ text.periods.filter((period) => {
125
+ const [left, right] = side === "before" ? [period, figure] : [figure, period];
126
+ return left.end <= right.start && within(bound, period) && gapLength(text.source, gapBetween(left, right)) <= PERIOD_GAP;
127
+ });
128
+
129
+ /**
130
+ * Of two values each dated by a year, the earlier year's. The years stand on the same side of both values: before them
131
+ * (「2025年は100万円、2026年は150万円」) or after them ("$1.0 million in 2025 and $1.5 million in 2026").
132
+ */
133
+ const datedEarlier = (text: Reading, first: Figure, second: Figure): Figure | undefined => {
134
+ const middle = gapBetween(first, second);
135
+ const sides = [
136
+ [yearsBeside(text, first, "before", { start: 0, end: first.start }), yearsBeside(text, second, "before", middle)],
137
+ [yearsBeside(text, first, "after", middle), yearsBeside(text, second, "after", { start: second.end, end: text.source.length })],
138
+ ].filter(([early, late]) => early?.length === 1 && late?.length === 1);
139
+ const [[early, late] = []] = sides;
140
+ const [firstYear, secondYear] = [early?.[0]?.year, late?.[0]?.year];
141
+ if (sides.length !== 1 || firstYear === undefined || secondYear === undefined || firstYear === secondYear) return undefined;
142
+ return firstYear < secondYear ? first : second;
143
+ };
144
+
145
+ /** Which of two values written together is the earlier: the one marked (「から」, "from", 「前年の」), else the one dated earlier. */
146
+ const pairOf = (text: Reading, first: Figure, second: Figure): Pair | undefined => {
147
+ const marked = [first, second].filter((figure) => markOf(figure, text.marks) !== undefined);
148
+ if (marked.length > 1) return undefined;
149
+ const base = marked[0] ?? datedEarlier(text, first, second);
150
+ if (base === undefined) return undefined;
151
+ return { base, current: base === first ? second : first };
152
+ };
153
+
154
+ /** Nothing between the later value and the rate that makes the rate another one's: a value, another subject, another base. */
155
+ const leadsTo = (text: Reading, second: Figure): boolean => {
156
+ const gap = gapBetween(second, text.rate);
157
+ if (gap.start > gap.end || gapLength(text.source, gap) > PAIR_REACH) return false;
158
+ return ![...text.figures, ...text.breaks, ...text.marks].some((span) => within(gap, span));
159
+ };
160
+
161
+ /** Two values of one subject written close, with no rate between them (「売上は100万円、費用は150万円」 is two subjects). */
162
+ const standTogether = (text: Reading, first: Figure, second: Figure): boolean => {
163
+ const gap = gapBetween(first, second);
164
+ const inGap = (span: Span): boolean => within(gap, span);
165
+ if (gapLength(text.source, gap) > PAIR_GAP || text.rates.some(inGap)) return false;
166
+ return !text.breaks.some((word) => !word.beforeRateOnly && inGap(word));
167
+ };
168
+
169
+ /** The two values written together right before the rate (「昨年の100万円から今年は150万円になり、前年比20%の増加」). */
170
+ const beforeRate = (text: Reading): Pair | undefined => {
171
+ const pairs = text.figures.flatMap((first, index) => {
172
+ const second = text.figures.slice(index + 1).find((figure) => figure.unit === first.unit);
173
+ const pair = second !== undefined && standTogether(text, first, second) && leadsTo(text, second) ? pairOf(text, first, second) : undefined;
174
+ return pair === undefined ? [] : [pair];
175
+ });
176
+ const [pair] = pairs;
177
+ return pairs.length === 1 ? pair : undefined;
178
+ };
179
+
180
+ /**
181
+ * The sentence's one rate with a word of direction beside it, a word counting for the rate it stands nearest. A percentage
182
+ * without one (「20%の増加、利益率は10%」) is about something else.
183
+ */
184
+ const readingIn = (text: ChangeText, sentence: Span): Reading | undefined => {
185
+ const inSentence = (span: Span): boolean => within(sentence, span);
186
+ const directions = text.directions.filter(inSentence);
187
+ const rates = text.rates.filter(inSentence);
188
+ const nearestTo = (rate: Rate, direction: Direction): boolean => rates.every((other) => distance(direction, rate) <= distance(direction, other));
189
+ const signed = rates.flatMap((rate) => {
190
+ const sign = signNear(
191
+ rate,
192
+ directions.filter((direction) => nearestTo(rate, direction)),
193
+ );
194
+ return sign === undefined ? [] : [{ rate, sign }];
195
+ });
196
+ const [only] = signed;
197
+ if (signed.length !== 1 || only === undefined) return undefined;
198
+ const parts = {
199
+ figures: text.figures.filter(inSentence).toSorted((left, right) => left.start - right.start),
200
+ periods: text.periods.filter(inSentence),
201
+ breaks: text.breaks.filter(inSentence),
202
+ };
203
+ return { ...text, ...parts, ...only, rates };
204
+ };
205
+
206
+ const issueIn = (text: ChangeText, sentence: Span): StructureIssue[] => {
207
+ const reading = readingIn(text, sentence);
208
+ const pair = reading === undefined ? undefined : (besideRate(reading) ?? beforeRate(reading));
209
+ const range = pair === undefined ? undefined : rateRange(pair.base, pair.current);
210
+ if (reading === undefined || pair === undefined || range === undefined) return [];
211
+ const { rate, sign } = reading;
95
212
  const written = sign * rate.value;
96
213
  const slack = HALF * DECIMAL_BASE ** -rate.decimals;
97
214
  if (written >= range[0] - slack && written <= range[1] + slack) return [];
98
- const computed = Math.abs((current.value / base.value - 1) * PERCENT).toFixed(rate.decimals);
215
+ const computed = Math.abs((pair.current.value / pair.base.value - 1) * PERCENT).toFixed(rate.decimals);
99
216
  return [{ offset: rate.start, values: { rate: String(rate.value), computed } }];
100
217
  };
101
218
 
102
- /** Each sentence with one rate of change, one direction, one marked earlier value and one other value in its unit, that disagree. */
219
+ /**
220
+ * Each sentence with one rate of change and its direction, and the two values it is computed from, that disagree: the
221
+ * earlier value marked beside the rate, or the two values written together right before it.
222
+ */
103
223
  export const changeRateMismatches = (text: ChangeText): StructureIssue[] => text.sentences.flatMap((sentence) => issueIn(text, sentence));
@@ -0,0 +1,35 @@
1
+ import type { Token } from "../plugin.ts";
2
+
3
+ // Whether a joining word ("and") starts a clause about another subject: "Revenue was $1,000 in 2025 and costs were $1,500
4
+ // in 2026" joins two subjects, "… in 2025 and $1,500 in 2026" or "… and revenue was $1,500" one. Two words for one thing
5
+ // ("sales" and "revenue") read as two subjects, so the pair is left alone rather than read wrongly. Pure: reads the tagger's
6
+ // parts of speech and lemmas.
7
+
8
+ const NOUN: ReadonlySet<string> = new Set(["NOUN", "PROPN"]);
9
+ /** Words that may stand before the noun that starts the clause ("and the costs", "and our operating costs"). */
10
+ const BEFORE_NOUN: ReadonlySet<string> = new Set(["DET", "ADJ", "PRON"]);
11
+
12
+ const wordOf = (token: Token): string => (token.lemma ?? token.surface).toLowerCase();
13
+
14
+ /** The last noun of the run of nouns at the start of tokens, past determiners and adjectives ("the operating costs" → cost). */
15
+ const leadingNoun = (tokens: readonly Token[]): Token | undefined => {
16
+ const start = tokens.findIndex((token) => !BEFORE_NOUN.has(token.pos));
17
+ if (start < 0) return undefined;
18
+ const end = tokens.slice(start).findIndex((token) => !NOUN.has(token.pos));
19
+ const nouns = tokens.slice(start, end < 0 ? tokens.length : start + end);
20
+ return nouns.at(-1);
21
+ };
22
+
23
+ /**
24
+ * The joining word at index is followed by a noun the sentence has not used before it: "and costs were", "and costs $1,500"
25
+ * after "Revenue was $1,000", where "and revenue was" after "ACME's revenue was $1,000" names the same thing again. A
26
+ * sentence with no noun before the joining word gives nothing to compare with.
27
+ */
28
+ export const startsOtherSubject = (tokens: readonly Token[], index: number): boolean => {
29
+ const next = leadingNoun(tokens.slice(index + 1));
30
+ const before = tokens
31
+ .slice(0, index)
32
+ .filter((token) => NOUN.has(token.pos))
33
+ .map(wordOf);
34
+ return next !== undefined && before.length > 0 && !before.includes(wordOf(next));
35
+ };