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
@@ -1,9 +1,10 @@
1
1
  // change-rate-mismatch: the reading half. Builds the figures, the rates and the words of structure/change-rate.ts from the
2
- // tree's quantities, the numbers counted with a word ("1,200 companies"), and the lexicons change-direction, change-base,
3
- // percent-unit and amount-multiplier.
4
- import type { Detector, Finding, ProseDocument, Span, StructureNode } from "../plugin.ts";
2
+ // tree's quantities, the numbers counted with a word ("1,200 companies"), the calendar years, and the lexicons change-direction,
3
+ // change-base, change-target, change-break, percent-unit and amount-multiplier.
4
+ import type { Detector, Finding, LexiconEntry, ProseDocument, Span, StructureNode, Token } from "../plugin.ts";
5
5
  import { inDocumentOrder } from "../structure/issues.ts";
6
- import { changeRateMismatches, type BaseMark, type Direction, type Figure, type Rate } from "../structure/change-rate.ts";
6
+ import { startsOtherSubject } from "../structure/subject-change.ts";
7
+ import { changeRateMismatches, type BaseMark, type Break, type Direction, type Figure, type Period, type Rate } from "../structure/change-rate.ts";
7
8
  import { escapeRegExp } from "../orthography.ts";
8
9
  import { quoteAt } from "./structure-tree.ts";
9
10
 
@@ -82,13 +83,52 @@ const spansOf = (source: string, word: string): Span[] => {
82
83
 
83
84
  const SIGNS: Readonly<Record<string, 1 | -1>> = { rise: 1, fall: -1 };
84
85
 
85
- /** Phrases that hold a direction word but give no direction ("up to 25%" is a ceiling): lexicon change-direction-not. */
86
- const directionsOf = (doc: ProseDocument): Direction[] => {
87
- const not = patternsOf(doc, "change-direction-not").flatMap((phrase) => spansOf(doc.source, phrase));
88
- return (doc.lexicons["change-direction"] ?? []).flatMap((entry) => {
86
+ /** Where the words of a lexicon stand, less those inside a phrase of its "-not" lexicon ("up to 25%" is a ceiling). */
87
+ const spansWithout = (doc: ProseDocument, entries: readonly LexiconEntry[], notLexicon: string): (Span & { readonly entry: LexiconEntry })[] => {
88
+ const not = patternsOf(doc, notLexicon).flatMap((phrase) => spansOf(doc.source, phrase));
89
+ return entries.flatMap((entry) =>
90
+ spansOf(doc.source, entry.pattern)
91
+ .filter((span) => !not.some((phrase) => phrase.start <= span.start && span.end <= phrase.end))
92
+ .map((span) => ({ ...span, entry })),
93
+ );
94
+ };
95
+
96
+ /** Lexicon change-direction, less the phrases of change-direction-not. */
97
+ const directionsOf = (doc: ProseDocument): Direction[] =>
98
+ spansWithout(doc, doc.lexicons["change-direction"] ?? [], "change-direction-not").flatMap(({ start, end, entry }) => {
89
99
  const sign = SIGNS[entry.group ?? ""];
90
- const spans = spansOf(doc.source, entry.pattern).filter((span) => !not.some((phrase) => phrase.start <= span.start && span.end <= phrase.end));
91
- return sign === undefined ? [] : spans.map((span) => ({ ...span, sign }));
100
+ return sign === undefined ? [] : [{ start, end, sign }];
101
+ });
102
+
103
+ /** The sentence's tokens, and the index of the token that starts at offset (-1 when none does). */
104
+ const tokenAt = (doc: ProseDocument, offset: number): { readonly tokens: readonly Token[]; readonly index: number } => {
105
+ const tokens = doc.sentences.find((sentence) => sentence.span.start <= offset && offset < sentence.span.end)?.tokens ?? [];
106
+ return { tokens, index: tokens.findIndex((token) => token.span.start === offset) };
107
+ };
108
+
109
+ /** A word that may join two values ("and") does not when another subject follows it ("and costs were"). */
110
+ const joinsTwoValues = (doc: ProseDocument, start: number): boolean => {
111
+ const { tokens, index } = tokenAt(doc, start);
112
+ return index < 0 || !startsOtherSubject(tokens, index);
113
+ };
114
+
115
+ /** Lexicon change-break (「は」, "and"), less the phrases of change-break-not (「上がり」 holds が but starts no subject). */
116
+ const breaksOf = (doc: ProseDocument): Break[] =>
117
+ spansWithout(doc, doc.lexicons["change-break"] ?? [], "change-break-not").map(({ start, end, entry }) => ({
118
+ start,
119
+ end,
120
+ beforeRateOnly: entry.group === "rate" && joinsTwoValues(doc, start),
121
+ }));
122
+
123
+ /** A calendar year written alone or with a unit of calendar year (2025, 2025年, 2025年度), and not part of an amount ($2025). */
124
+ const CALENDAR_YEAR = /(?<!\d|\d[.,])(?:1[89]|2[01])\d{2}(?!\d|[.,]\d)/gu;
125
+
126
+ const periodsOf = (doc: ProseDocument, figures: readonly Figure[]): Period[] => {
127
+ const yearUnits = new Set(patternsOf(doc, "calendar-year-unit").map((unit) => `${unit}|`));
128
+ const amounts = figures.filter((figure) => !yearUnits.has(figure.unit));
129
+ return [...doc.source.matchAll(CALENDAR_YEAR)].flatMap((match) => {
130
+ const span = { start: match.index, end: match.index + match[0].length };
131
+ return amounts.some((figure) => figure.start < span.end && span.start < figure.end) ? [] : [{ ...span, year: Number(match[0]) }];
92
132
  });
93
133
  };
94
134
 
@@ -103,13 +143,17 @@ export const changeRate: Detector = (doc): Finding[] => {
103
143
  const units = patternsOf(doc, "percent-unit");
104
144
  const multipliers = patternsOf(doc, "amount-multiplier").map((word) => word.toLowerCase());
105
145
  const taken = nodes.map((node) => node.span);
146
+ const figures = [...treeFigures(doc, nodes, units, multipliers), ...countedFigures(doc, taken, multipliers)];
106
147
  const text = {
107
148
  sentences: doc.sentences.map((sentence) => sentence.span),
108
- figures: [...treeFigures(doc, nodes, units, multipliers), ...countedFigures(doc, taken, multipliers)],
149
+ figures,
109
150
  rates: ratesOf(doc, nodes, units),
110
151
  directions: directionsOf(doc),
111
152
  marks: marksOf(doc, "change-base"),
112
153
  targets: marksOf(doc, "change-target"),
154
+ periods: periodsOf(doc, figures),
155
+ breaks: breaksOf(doc),
156
+ source: doc.source,
113
157
  };
114
158
  return changeRateMismatches(text).map((issue) => ({
115
159
  rule: "change-rate-mismatch",
@@ -14,7 +14,7 @@ export type WrittenAmount = { readonly offset: number; readonly written: string;
14
14
  /** 少ないほうの書き方の金額と、多いほうの書き方の例、その通貨の金額の数。 */
15
15
  export type CurrencyMinority = { readonly odd: WrittenAmount; readonly usual: string; readonly count: number; readonly of: number };
16
16
 
17
- const AMOUNT = "[0-90-9]+(?:[,,][0-90-9]{3})*(?:[..][0-90-9]+)?";
17
+ export const AMOUNT = "[0-90-9]+(?:[,,][0-90-9]{3})*(?:[..][0-90-9]+)?";
18
18
 
19
19
  const alternation = (words: readonly string[]): string => words.map(escapeRegExp).join("|");
20
20
 
@@ -71,7 +71,7 @@ export const currencyMinorities = (amounts: readonly WrittenAmount[], limit: num
71
71
  )
72
72
  .toSorted((left, right) => left.odd.offset - right.odd.offset);
73
73
 
74
- const formsOf = (doc: ProseDocument): CurrencyForm[] =>
74
+ export const formsOf = (doc: ProseDocument): CurrencyForm[] =>
75
75
  (doc.lexicons["currency-notation"] ?? []).flatMap((entry) =>
76
76
  entry.position === undefined || entry.group === undefined ? [] : [{ pattern: entry.pattern, position: entry.position, currency: entry.group }],
77
77
  );
@@ -5,6 +5,7 @@ import { overlapsAny, spanIndex } from "../compare/spans.ts";
5
5
  import { yearOf, type DurationUnit } from "../derived/date-arithmetic.ts";
6
6
  import { durationMismatches, type DatedValue, type Duration } from "../derived/durations.ts";
7
7
  import { elapsedMismatches, type Elapsed, type OriginWord, type Year } from "../derived/elapsed.ts";
8
+ import { numberWordCounts } from "../derived/number-word-counts.ts";
8
9
  import { quoteAt } from "./structure-tree.ts";
9
10
 
10
11
  /** 期間の単位の語彙表。木の数量の単位がどれかに入れば、その単位の期間。 */
@@ -122,8 +123,14 @@ const isAge = (doc: ProseDocument, quantity: Quantity): boolean =>
122
123
  positioned(doc, "age-marker", "before").some((word) => textBefore(doc, quantity).endsWith(word)) ||
123
124
  positioned(doc, "age-marker", "after").some((word) => textAfter(doc, quantity).startsWith(word));
124
125
 
125
- const elapsedOf = (doc: ProseDocument, quantities: readonly Quantity[]): Elapsed[] =>
126
- quantities.filter((quantity) => !isAge(doc, quantity) && patternsOf(doc, "elapsed-unit").some((unit) => unit.normalize("NFKC") === quantity.unit));
126
+ /** 年数: 単位が年数の語の数量(10 years、創業10年)と、木が読まない語で書いた数(ten years)。 */
127
+ const elapsedOf = (doc: ProseDocument, quantities: readonly Quantity[]): Elapsed[] => {
128
+ const units = patternsOf(doc, "elapsed-unit");
129
+ const counted = quantities.filter((quantity) => !isAge(doc, quantity) && units.some((unit) => unit.normalize("NFKC") === quantity.unit));
130
+ const taken = spanIndex(quantities);
131
+ const worded = numberWordCounts(doc.source, patternsOf(doc, "count-number"), units).filter((count) => !overlapsAny(taken, count));
132
+ return [...counted, ...worded].toSorted((left, right) => left.start - right.start);
133
+ };
127
134
 
128
135
  /** 「aged 45」の 45: 英語の木は単位の無い数を読まないので、年齢の印のすぐ後ろの数を読む。 */
129
136
  const AGE_NUMBER = /^\s*(\d{1,3})(?!\d)/u;
@@ -1,4 +1,5 @@
1
1
  import type { Detector, Finding, ProseDocument, StructureNode } from "../plugin.ts";
2
+ import { countedFacts, countedPhraseAt, type CountedPhrase } from "../facts/counted-facts.ts";
2
3
  import { nameSpans } from "../compare/proper-nouns.ts";
3
4
  import { factValues, type FactValue } from "../facts/fact-values.ts";
4
5
  import { labelledFacts, type AttributePhrase, type FactWords } from "../facts/labelled-facts.ts";
@@ -50,5 +51,20 @@ const findingOf =
50
51
  /** 同じ節で、同じ名前に二通りの値(締切:10月5日 と 締切:10月7日)。 */
51
52
  export const factConflict: Detector = (doc): Finding[] => scopeConflicts(factsOf(doc)).map(findingOf("fact-conflict", doc));
52
53
 
54
+ const countedPhrasesOf = (doc: ProseDocument): CountedPhrase[] =>
55
+ doc.sentences.flatMap((sentence) => {
56
+ const tokens = sentence.tokens ?? [];
57
+ return tokens.flatMap((token, index) => (token.pos === "NUM" ? countedPhraseAt(tokens, index) : []));
58
+ });
59
+
60
+ /** The values named by what they count, scoped like the labelled ones. Only the summary is compared with them. */
61
+ const countedFactsOf = (doc: ProseDocument): ScopedFact[] => {
62
+ const tree = doc.structure;
63
+ if (tree === undefined) return [];
64
+ const values = factValues(tree, doc.source, nameSpans(doc));
65
+ return scopedFacts(countedFacts(doc.source, values, countedPhrasesOf(doc)), tree, doc.source, patternsOf(doc, "summary-heading"));
66
+ };
67
+
53
68
  /** 冒頭や要約の値が、本文の同じ名前の値と違う。 */
54
- export const summaryFactMismatch: Detector = (doc): Finding[] => summaryConflicts(factsOf(doc)).map(findingOf("summary-fact-mismatch", doc));
69
+ export const summaryFactMismatch: Detector = (doc): Finding[] =>
70
+ summaryConflicts([...factsOf(doc), ...countedFactsOf(doc)]).map(findingOf("summary-fact-mismatch", doc));
@@ -1,10 +1,19 @@
1
- import type { Detector, Finding } from "../plugin.ts";
2
- import { mentionsIn, nameVariants } from "../name-variants.ts";
1
+ import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
+ import { mentionsIn, nameVariants, suffixedNamesIn, type VariantChars } from "../name-variants.ts";
3
3
  import { quoteAt } from "./structure-tree.ts";
4
4
 
5
+ // 人の名前と読ませる敬称(様、さん)は語彙表 person-suffix、字体の違う同じ字(斎・斉・齋)は name-variant-char が組(group)ごとに言う。
6
+
7
+ const variantCharsOf = (doc: ProseDocument): VariantChars =>
8
+ new Map((doc.lexicons["name-variant-char"] ?? []).flatMap((entry): [string, string][] => (entry.group === undefined ? [] : [[entry.pattern, entry.group]])));
9
+
5
10
  /** 同じ名前を、文書の中で少しだけ違う形に書いた所(GitHub と Github、山田太郎 と 山田太朗)。少ないほうを指す。 */
6
- export const nameVariant: Detector = (doc): Finding[] =>
7
- nameVariants(doc.sentences.flatMap((sentence) => mentionsIn(sentence.tokens ?? [], doc.source))).map(({ mention, usual, kind }) => ({
11
+ export const nameVariant: Detector = (doc): Finding[] => {
12
+ const suffixes = (doc.lexicons["person-suffix"] ?? []).map((entry) => entry.pattern);
13
+ const chars = variantCharsOf(doc);
14
+ const tagged = doc.sentences.flatMap((sentence) => mentionsIn(sentence.tokens ?? [], doc.source, suffixes));
15
+ const mentions = [...tagged, ...suffixedNamesIn(doc.prose ?? doc.source, suffixes, chars, tagged)].toSorted((left, right) => left.offset - right.offset);
16
+ return nameVariants(mentions, chars).map(({ mention, usual, kind }) => ({
8
17
  rule: "",
9
18
  severity: "warning",
10
19
  line: 0,
@@ -13,3 +22,4 @@ export const nameVariant: Detector = (doc): Finding[] =>
13
22
  values: { name: mention.surface, usual, offset: mention.offset },
14
23
  variant: kind,
15
24
  }));
25
+ };
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { amountScale } from "../amount-scale.ts";
3
+
4
+ export const detector: Detector = amountScale;
@@ -0,0 +1,59 @@
1
+ import type { Span, Token } from "../plugin.ts";
2
+ import type { FactValue } from "./fact-values.ts";
3
+ import type { Fact } from "./labelled-facts.ts";
4
+
5
+ // A value with no label, named by who counts, the verb that counts and what is counted ("We gained 52 new customers": we
6
+ // + gain + new customer). A summary that states such a value is compared with the body's value for the same three. Pure:
7
+ // reads the tagger's parts of speech and lemmas.
8
+
9
+ /** The words right after a number that say what it counts, and the key of who counts it with which verb. */
10
+ export type CountedPhrase = Span & { readonly label: string; readonly key: string };
11
+
12
+ /** How many words after a number may name what it counts ("new enterprise customers"). */
13
+ const MAX_COUNTED_WORDS = 3;
14
+ const COUNTED_WORD: ReadonlySet<string> = new Set(["ADJ", "NOUN"]);
15
+ const SUBJECT_WORD: ReadonlySet<string> = new Set(["NOUN", "PROPN", "PRON"]);
16
+ /** A number right after a preposition is part of something larger: the pool in "1 of 3 proposals". */
17
+ const NOT_A_COUNT_AFTER: ReadonlySet<string> = new Set(["ADP", "NUM"]);
18
+
19
+ /** The number is the end of a range ("between 10 and 12 customers") or of a pair ("1 of 3"). */
20
+ const isPartOfLarger = (tokens: readonly Token[], index: number): boolean => {
21
+ const [twoBefore, before] = [tokens[index - 2], tokens[index - 1]];
22
+ return NOT_A_COUNT_AFTER.has(before?.pos ?? "") || (before?.pos === "CCONJ" && twoBefore?.pos === "NUM");
23
+ };
24
+
25
+ const wordOf = (token: Token): string => (token.lemma ?? token.surface).toLowerCase();
26
+
27
+ /** The adjectives and nouns right after the number at index, up to the last noun ("new customers"). */
28
+ const countedWords = (tokens: readonly Token[], index: number): Token[] => {
29
+ const after = tokens.slice(index + 1, index + 1 + MAX_COUNTED_WORDS);
30
+ const stop = after.findIndex((token) => !COUNTED_WORD.has(token.pos));
31
+ const run = stop < 0 ? after : after.slice(0, stop);
32
+ return run.slice(0, run.findLastIndex((token) => token.pos === "NOUN") + 1);
33
+ };
34
+
35
+ /**
36
+ * What the number at index in a sentence's tokens counts: the words after it, the nearest verb before it, and the nearest
37
+ * noun or pronoun before that verb (who counts). None when one of the three is missing or the number is part of a larger one.
38
+ */
39
+ export const countedPhraseAt = (tokens: readonly Token[], index: number): CountedPhrase[] => {
40
+ if (isPartOfLarger(tokens, index)) return [];
41
+ const words = countedWords(tokens, index);
42
+ const verbAt = tokens.slice(0, index).findLastIndex((token) => token.pos === "VERB");
43
+ const subject = tokens.slice(0, Math.max(0, verbAt)).findLast((token) => SUBJECT_WORD.has(token.pos));
44
+ const [first, last, verb] = [words[0], words.at(-1), tokens[verbAt]];
45
+ if (first === undefined || last === undefined || verb === undefined || subject === undefined) return [];
46
+ const key = ["counted", wordOf(subject), wordOf(verb), ...words.map(wordOf)].join("\u0000");
47
+ return [{ start: first.span.start, end: last.span.end, label: words.map((token) => token.surface).join(" "), key }];
48
+ };
49
+
50
+ /** A calendar year ("2026 revenue") is a point in time, not a count. */
51
+ const YEAR = /^(?:1[89]|2[01])\d{2}$/u;
52
+
53
+ /** The fact of each number with no unit that a counted phrase follows after one character (the space). */
54
+ export const countedFacts = (source: string, values: readonly FactValue[], phrases: readonly CountedPhrase[]): Fact[] =>
55
+ values.flatMap((value) => {
56
+ if (value.kind !== "quantity" || value.unit !== "" || YEAR.test(source.slice(value.start, value.end))) return [];
57
+ const phrase = phrases.find((candidate) => candidate.start === value.end + 1);
58
+ return phrase === undefined ? [] : [{ label: phrase.label, key: phrase.key, value }];
59
+ });
@@ -1,13 +1,26 @@
1
1
  import type { Token } from "./plugin.ts";
2
+ import { escapeRegExp } from "./orthography.ts";
2
3
 
3
4
  // 同じ名前(人・会社・製品)を、文書の中で少しだけ違う形に書いた所。どの形が正しいかは決めず、少ないほうを指す。
4
5
  // 三通りで同じ名前と見る。書き方の違いだけ(GitHub と Github、Mac OS と macOS)、読みが同じ(山田太郎 と 山田太朗)、
5
6
  // 英字の一字違い(Microsoft と Microsft)。一字違いは別の名前でもありうるので、多いほうが二度以上、少ないほうが一度だけのときに限る。
6
7
 
7
- /** words は名前の語(記号を除く)。読みが同じ二つの名前が、どの語で違うかを見るため。 */
8
- export type NameMention = { readonly surface: string; readonly offset: number; readonly reading: string | undefined; readonly words: readonly string[] };
8
+ /**
9
+ * words は名前の語(記号を除く)。読みが同じ二つの名前が、どの語で違うかを見るため。person は人の名前と読めるもの(解析器が
10
+ * 人名と読むか、敬称が付く)。
11
+ */
12
+ export type NameMention = {
13
+ readonly surface: string;
14
+ readonly offset: number;
15
+ readonly reading: string | undefined;
16
+ readonly words: readonly string[];
17
+ readonly person?: boolean;
18
+ };
19
+
20
+ export type NameVariant = { readonly mention: NameMention; readonly usual: string; readonly kind: "spelling" | "reading" | "near" | "character" };
9
21
 
10
- export type NameVariant = { readonly mention: NameMention; readonly usual: string; readonly kind: "spelling" | "reading" | "near" };
22
+ /** 字体の違う同じ字(斎・斉・齋)。字から、その組の代表の字へ。 */
23
+ export type VariantChars = ReadonlyMap<string, string>;
11
24
 
12
25
  const NAME = "PROPN";
13
26
  /**
@@ -58,11 +71,18 @@ const withoutEdgeQuotes = (text: string): string => {
58
71
  */
59
72
  const NOT_COMPARED = /@|(?:^|\s)\p{L}(?:\s|$)/u;
60
73
 
74
+ /** 人名と読む解析器の印(UD の NameType: 姓 Sur、名 Giv、どちらとも言えない人名 Prs)。 */
75
+ const PERSON_TYPES: ReadonlySet<string> = new Set(["Sur", "Giv", "Prs"]);
76
+
77
+ /** 人の名前か。解析器が人名と読む語を含むか、すぐ後ろに敬称(様、さん、氏)が付く。 */
78
+ const isPerson = (run: readonly Token[], next: Token | undefined, suffixes: readonly string[]): boolean =>
79
+ run.some((token) => PERSON_TYPES.has(token.features?.["NameType"] ?? "")) || (next !== undefined && suffixes.includes(next.surface));
80
+
61
81
  /**
62
82
  * 文の語から、名前の現れ。surface は source の上の書いたまま(折り返しの空白は一つにまとめる)。
63
83
  * 大文字だけの名前(ACME INC、NASA)は外す。契約書の署名欄や略語で、ふつうの書き方の別の形ではない。小文字の英字だけの語も外す。
64
84
  */
65
- export const mentionsIn = (tokens: readonly Token[], source: string): NameMention[] =>
85
+ export const mentionsIn = (tokens: readonly Token[], source: string, personSuffixes: readonly string[] = []): NameMention[] =>
66
86
  runsOf(tokens).flatMap((run) => {
67
87
  const first = run[0];
68
88
  const last = run.at(-1);
@@ -73,9 +93,39 @@ export const mentionsIn = (tokens: readonly Token[], source: string): NameMentio
73
93
  const words = run.filter((token) => !JOINERS.has(token.surface));
74
94
  const readings = words.map((token) => token.reading);
75
95
  const reading = readings.every((value) => value !== undefined && value !== "") ? readings.join("") : undefined;
76
- return [{ surface, offset: first.span.start, reading, words: words.map((token) => token.surface) }];
96
+ const person = isPerson(run, tokens[tokens.indexOf(last) + 1], personSuffixes);
97
+ return [{ surface, offset: first.span.start, reading, words: words.map((token) => token.surface), person }];
77
98
  });
78
99
 
100
+ /** 敬称の前の名前の字数(髙橋、髙橋太郎)。長い漢字の連なり(株式会社髙橋)は名前だけを切り出せない。 */
101
+ const MAX_SUFFIXED_NAME = 4;
102
+ const MIN_SUFFIXED_NAME = 2;
103
+ /** 敬称の前を読む範囲(UTF-16 の単位)。名前より長い漢字の連なりを、長いまま見るため。 */
104
+ const SUFFIX_LOOKBACK = 20;
105
+ const HAN = /^\p{Script=Han}$/u;
106
+
107
+ /** 文字列の終わりの漢字の連なり。 */
108
+ const trailingHan = (text: string): string => {
109
+ const letters = [...text];
110
+ return letters.slice(letters.findLastIndex((letter) => !HAN.test(letter)) + 1).join("");
111
+ };
112
+
113
+ /**
114
+ * 解析器が名前と読めなかった、字体の違う字を含む人の名前(髙橋様、𠮷田様、﨑山様)。敬称のすぐ前の漢字の連なりで、語彙表の字を
115
+ * 含み、ほかの名前の現れと重ならないもの。
116
+ */
117
+ export const suffixedNamesIn = (source: string, suffixes: readonly string[], chars: VariantChars, taken: readonly NameMention[]): NameMention[] =>
118
+ suffixes.flatMap((suffix) =>
119
+ [...source.matchAll(new RegExp(escapeRegExp(suffix), "gu"))].flatMap((match) => {
120
+ const run = trailingHan(source.slice(Math.max(0, match.index - SUFFIX_LOOKBACK), match.index));
121
+ const length = [...run].length;
122
+ if (length < MIN_SUFFIXED_NAME || length > MAX_SUFFIXED_NAME || ![...run].some((letter) => chars.has(letter))) return [];
123
+ const offset = match.index - run.length;
124
+ if (taken.some((mention) => mention.offset < match.index && offset < mention.offset + mention.surface.length)) return [];
125
+ return [{ surface: run, offset, reading: undefined, words: [run], person: true }];
126
+ }),
127
+ );
128
+
79
129
  /** 比べる形。幅(AWS と AWS)、大文字小文字、空白と記号は名前を変えない。 */
80
130
  export const nameKey = (surface: string): string =>
81
131
  surface
@@ -83,14 +133,19 @@ export const nameKey = (surface: string): string =>
83
133
  .toLowerCase()
84
134
  .replaceAll(/[\s\p{P}\p{S}]/gu, "");
85
135
 
86
- type Tally = { readonly surface: string; readonly first: NameMention; readonly count: number };
136
+ /** person: どれかの現れが人の名前と読める。 */
137
+ type Tally = { readonly surface: string; readonly first: NameMention; readonly count: number; readonly person: boolean };
87
138
 
88
139
  const talliesOf = (mentions: readonly NameMention[]): Tally[] => {
89
- const tallies = new Map<string, { surface: string; first: NameMention; count: number }>();
140
+ const tallies = new Map<string, { surface: string; first: NameMention; count: number; person: boolean }>();
90
141
  mentions.forEach((mention) => {
91
142
  const tally = tallies.get(mention.surface);
92
- if (tally === undefined) tallies.set(mention.surface, { surface: mention.surface, first: mention, count: 1 });
93
- else tally.count += 1;
143
+ const person = mention.person === true;
144
+ if (tally === undefined) tallies.set(mention.surface, { surface: mention.surface, first: mention, count: 1, person });
145
+ else {
146
+ tally.count += 1;
147
+ tally.person ||= person;
148
+ }
94
149
  });
95
150
  return [...tallies.values()];
96
151
  };
@@ -191,8 +246,46 @@ const isSlipOf = (tally: Tally, other: Tally): boolean => {
191
246
 
192
247
  const nearVariants = (tallies: readonly Tally[]): NameVariant[] => variantsIn(groupBy(tallies, maskedKeys), "near", isSlipOf);
193
248
 
194
- /** 同じ名前の、少ないほうの書き方。書き方ごとに最初の現れを一つ。同じ現れを二つの見方が言えば、先の見方だけ。 */
195
- export const nameVariants = (mentions: readonly NameMention[]): NameVariant[] => {
249
+ /** 字体の違う字(斎と斉)を組の代表の字に寄せた形。 */
250
+ const characterKey = (tally: Tally, chars: VariantChars): string => [...tally.surface].map((letter) => chars.get(letter) ?? letter).join("");
251
+
252
+ /** 人の名前を、字体の違う同じ字(斎藤 と 斉藤、渡辺 と 渡邊)で書き分けた所。 */
253
+ const characterVariants = (tallies: readonly Tally[], chars: VariantChars): NameVariant[] =>
254
+ variantsIn(
255
+ groupBy(
256
+ tallies.filter((tally) => tally.person),
257
+ (tally) => [characterKey(tally, chars)],
258
+ ),
259
+ "character",
260
+ );
261
+
262
+ /** 字数が同じで、一字だけが違う(松本 と 松元)。 */
263
+ const oneCharApart = (left: string, right: string): boolean => {
264
+ const [leftChars, rightChars] = [[...left], [...right]];
265
+ return leftChars.length === rightChars.length && leftChars.filter((char, index) => char !== rightChars[index]).length === 1;
266
+ };
267
+
268
+ /**
269
+ * 読みが同じ人の名前が、一字だけ違う(松本 と 松元)。読みの同じ別の姓(伊藤 と 伊東)もあるので、多いほうが二度以上、
270
+ * 少ないほうが一度だけのときに限る。
271
+ */
272
+ const isPersonSlipOf = (tally: Tally, other: Tally): boolean => tally.count === 1 && other.count >= 2 && oneCharApart(tally.surface, other.surface);
273
+
274
+ const personReadingVariants = (tallies: readonly Tally[]): NameVariant[] =>
275
+ variantsIn(
276
+ groupBy(
277
+ tallies.filter((tally) => tally.person),
278
+ (tally) => (tally.first.reading === undefined ? [] : [tally.first.reading]),
279
+ ),
280
+ "reading",
281
+ isPersonSlipOf,
282
+ );
283
+
284
+ /**
285
+ * 同じ名前の、少ないほうの書き方。書き方ごとに最初の現れを一つ。同じ現れを二つの見方が言えば、先の見方だけ。chars は字体の
286
+ * 違う同じ字の組(語彙表 name-variant-char)。
287
+ */
288
+ export const nameVariants = (mentions: readonly NameMention[], chars: VariantChars = new Map()): NameVariant[] => {
196
289
  const tallies = talliesOf(mentions);
197
290
  const found = [
198
291
  ...variantsIn(
@@ -205,6 +298,8 @@ export const nameVariants = (mentions: readonly NameMention[]): NameVariant[] =>
205
298
  oneWordApart,
206
299
  ),
207
300
  ...nearVariants(tallies),
301
+ ...characterVariants(tallies, chars),
302
+ ...personReadingVariants(tallies),
208
303
  ];
209
304
  const reported = new Set<string>();
210
305
  return found
@@ -0,0 +1,97 @@
1
+ // An amount written in digits where the document writes amounts of that size with a word of scale, or the other way
2
+ // round (250,000円 beside 30万円; $2,500,000 beside $1.5 million). Small amounts in digits and large ones with 万 or
3
+ // million is one way of writing, not a mix, so only amounts of the same order of magnitude are compared. Pure.
4
+
5
+ /**
6
+ * One amount: where it is, as written, its currency, its value, and the largest word of scale it was written with (unit:
7
+ * 10000 for 30万円, 1000000 for $1.5 million), undefined when it was written in digits only.
8
+ */
9
+ export type ScaledAmount = {
10
+ readonly offset: number;
11
+ readonly written: string;
12
+ readonly currency: string;
13
+ readonly value: number;
14
+ readonly unit: number | undefined;
15
+ };
16
+
17
+ /** The amount written the other way, and the first one of its size written the usual way. */
18
+ export type ScaleMix = { readonly odd: ScaledAmount; readonly usual: ScaledAmount };
19
+
20
+ /** A digit amount with more significant digits than this (1,234,567円) is a precise figure no word of scale would write. */
21
+ const MAX_SIGNIFICANT_DIGITS = 3;
22
+
23
+ const significantDigits = (value: number): number => {
24
+ const digits = [...String(value).replace(".", "")];
25
+ const first = digits.findIndex((digit) => digit !== "0");
26
+ return first < 0 ? 0 : digits.findLastIndex((digit) => digit !== "0") - first + 1;
27
+ };
28
+
29
+ const isScaled = (amount: ScaledAmount): boolean => amount.unit !== undefined;
30
+
31
+ /**
32
+ * Whether the amount could be written the other way. A digit one when it is round (250,000, not 250,500); a scaled one when
33
+ * it is at least one of its unit: "£0.2 million" is written to line up with larger amounts beside it, not to give a size.
34
+ */
35
+ const comparable = (amount: ScaledAmount): boolean =>
36
+ amount.value > 0 && (amount.unit === undefined ? significantDigits(amount.value) <= MAX_SIGNIFICANT_DIGITS : amount.value >= amount.unit);
37
+
38
+ /** The order of magnitude, from the digits of the whole part (Math.log10 is not exact at every power of ten). */
39
+ const magnitudeOf = (value: number): number => String(Math.floor(value)).length - 1;
40
+
41
+ const countScaled = (amounts: readonly ScaledAmount[]): number => amounts.filter(isScaled).length;
42
+
43
+ /** Whether the scaled way is the odd one in a size: the fewer there, then the fewer at that size and above, then the later. */
44
+ const oddIsScaled = (band: readonly ScaledAmount[], larger: readonly ScaledAmount[]): boolean => {
45
+ const scaledInBand = countScaled(band);
46
+ if (scaledInBand * 2 !== band.length) return scaledInBand * 2 < band.length;
47
+ const scaledLarger = countScaled(larger);
48
+ if (scaledLarger * 2 !== larger.length) return scaledLarger * 2 < larger.length;
49
+ const firstScaled = band.findIndex(isScaled);
50
+ const firstDigits = band.findIndex((amount) => !isScaled(amount));
51
+ return firstScaled > firstDigits;
52
+ };
53
+
54
+ const mixesOfCurrency = (amounts: readonly ScaledAmount[]): ScaleMix[] => {
55
+ const magnitudes = [...new Set(amounts.map((amount) => magnitudeOf(amount.value)))];
56
+ return magnitudes.flatMap((magnitude) => {
57
+ const band = amounts.filter((amount) => magnitudeOf(amount.value) === magnitude);
58
+ const scaledIsOdd = oddIsScaled(
59
+ band,
60
+ amounts.filter((amount) => magnitudeOf(amount.value) >= magnitude),
61
+ );
62
+ const usual = band.find((amount) => isScaled(amount) !== scaledIsOdd);
63
+ if (usual === undefined) return [];
64
+ return band.filter((amount) => isScaled(amount) === scaledIsOdd).map((amount) => ({ odd: amount, usual }));
65
+ });
66
+ };
67
+
68
+ /** For each currency, the amounts written the other way from the rest of their order of magnitude, in document order. */
69
+ export const scaleMixes = (amounts: readonly ScaledAmount[]): ScaleMix[] => {
70
+ const ordered = amounts.filter(comparable).toSorted((left, right) => left.offset - right.offset);
71
+ return [...new Set(ordered.map((amount) => amount.currency))]
72
+ .flatMap((currency) => mixesOfCurrency(ordered.filter((amount) => amount.currency === currency)))
73
+ .toSorted((left, right) => left.odd.offset - right.odd.offset);
74
+ };
75
+
76
+ /** A word of scale and the value it multiplies by (万 10000, million 1000000). */
77
+ export type ScaleWord = { readonly word: string; readonly value: number };
78
+
79
+ const NUMBER_THEN_WORD = /(\d+(?:,\d{3})*(?:\.\d+)?)\s?([^\s\d.,]*)/gu;
80
+
81
+ /**
82
+ * The value of an amount as written, without its currency: 1億2,000万 is 120000000, 1.5 million is 1500000. Undefined when a
83
+ * part is followed by a word that is not one of scale, or nothing is read.
84
+ */
85
+ export const amountValue = (written: string, words: readonly ScaleWord[]): number | undefined => {
86
+ const parts = [...written.normalize("NFKC").matchAll(NUMBER_THEN_WORD)];
87
+ if (parts.length === 0) return undefined;
88
+ const values = parts.map((part) => {
89
+ const number = Number((part[1] ?? "").replaceAll(",", ""));
90
+ const word = (part[2] ?? "").toLowerCase();
91
+ if (word === "") return number;
92
+ const scale = words.find((entry) => entry.word.toLowerCase() === word);
93
+ return scale === undefined ? Number.NaN : number * scale.value;
94
+ });
95
+ const total = values.reduce((sum, value) => sum + value, 0);
96
+ return Number.isFinite(total) ? total : undefined;
97
+ };