paperlint 6.0.0 → 6.2.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 (141) hide show
  1. package/dist/adapters/latex/index.d.ts +1 -1
  2. package/dist/adapters/latex/index.d.ts.map +1 -1
  3. package/dist/adapters/latex/index.js +3 -2
  4. package/dist/adapters/latex/index.js.map +1 -1
  5. package/dist/adapters/latex/prose.d.ts +6 -1
  6. package/dist/adapters/latex/prose.d.ts.map +1 -1
  7. package/dist/adapters/latex/prose.js +142 -31
  8. package/dist/adapters/latex/prose.js.map +1 -1
  9. package/dist/adapters/mp4box/index.d.ts +4 -0
  10. package/dist/adapters/mp4box/index.d.ts.map +1 -0
  11. package/dist/adapters/mp4box/index.js +46 -0
  12. package/dist/adapters/mp4box/index.js.map +1 -0
  13. package/dist/adapters/node/index.d.ts +1 -0
  14. package/dist/adapters/node/index.d.ts.map +1 -1
  15. package/dist/adapters/node/index.js +1 -0
  16. package/dist/adapters/node/index.js.map +1 -1
  17. package/dist/adapters/node/list-dir.io.d.ts +3 -0
  18. package/dist/adapters/node/list-dir.io.d.ts.map +1 -0
  19. package/dist/adapters/node/list-dir.io.js +15 -0
  20. package/dist/adapters/node/list-dir.io.js.map +1 -0
  21. package/dist/adapters/png/index.d.ts +11 -0
  22. package/dist/adapters/png/index.d.ts.map +1 -0
  23. package/dist/adapters/png/index.js +21 -0
  24. package/dist/adapters/png/index.js.map +1 -0
  25. package/dist/adapters/srt/index.d.ts +4 -0
  26. package/dist/adapters/srt/index.d.ts.map +1 -0
  27. package/dist/adapters/srt/index.js +12 -0
  28. package/dist/adapters/srt/index.js.map +1 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +40 -22
  31. package/dist/cli.js.map +1 -1
  32. package/dist/domain/register.d.ts +96 -0
  33. package/dist/domain/register.d.ts.map +1 -0
  34. package/dist/domain/register.js +136 -0
  35. package/dist/domain/register.js.map +1 -0
  36. package/dist/domain/talk.d.ts +121 -0
  37. package/dist/domain/talk.d.ts.map +1 -0
  38. package/dist/domain/talk.js +121 -0
  39. package/dist/domain/talk.js.map +1 -0
  40. package/dist/domain/tex-document.d.ts +19 -0
  41. package/dist/domain/tex-document.d.ts.map +1 -1
  42. package/dist/domain/tex-document.js +8 -3
  43. package/dist/domain/tex-document.js.map +1 -1
  44. package/dist/lib/paper-config.d.ts +2 -0
  45. package/dist/lib/paper-config.d.ts.map +1 -1
  46. package/dist/lib/paper-config.js +3 -0
  47. package/dist/lib/paper-config.js.map +1 -1
  48. package/dist/paper-settings.d.ts +3 -0
  49. package/dist/paper-settings.d.ts.map +1 -1
  50. package/dist/paper-settings.js +15 -9
  51. package/dist/paper-settings.js.map +1 -1
  52. package/dist/ports/latex.d.ts +6 -1
  53. package/dist/ports/latex.d.ts.map +1 -1
  54. package/dist/ports/talk-media.d.ts +18 -0
  55. package/dist/ports/talk-media.d.ts.map +1 -0
  56. package/dist/ports/talk-media.js +2 -0
  57. package/dist/ports/talk-media.js.map +1 -0
  58. package/dist/presets.d.ts +6 -0
  59. package/dist/presets.d.ts.map +1 -1
  60. package/dist/presets.js +8 -9
  61. package/dist/presets.js.map +1 -1
  62. package/dist/register-bands.d.ts +37 -0
  63. package/dist/register-bands.d.ts.map +1 -0
  64. package/dist/register-bands.js +196 -0
  65. package/dist/register-bands.js.map +1 -0
  66. package/dist/register.d.ts +4 -2
  67. package/dist/register.d.ts.map +1 -1
  68. package/dist/register.js +6 -45
  69. package/dist/register.js.map +1 -1
  70. package/dist/talk-rules.d.ts +65 -0
  71. package/dist/talk-rules.d.ts.map +1 -0
  72. package/dist/talk-rules.js +451 -0
  73. package/dist/talk-rules.js.map +1 -0
  74. package/dist/tex-requirements.d.ts +9 -0
  75. package/dist/tex-requirements.d.ts.map +1 -1
  76. package/dist/tex-requirements.js +28 -6
  77. package/dist/tex-requirements.js.map +1 -1
  78. package/dist/tex-venue-rules.d.ts +1 -1
  79. package/dist/tex-venue-rules.d.ts.map +1 -1
  80. package/dist/tex-venue-rules.js.map +1 -1
  81. package/docs/configuration.md +2 -1
  82. package/docs/rules/talk/captions-cover.md +47 -0
  83. package/docs/rules/talk/duration-floor.md +38 -0
  84. package/docs/rules/talk/duration.md +43 -0
  85. package/docs/rules/talk/one-slide-size.md +38 -0
  86. package/docs/rules/talk/profile.md +64 -0
  87. package/docs/rules/talk/required-files.md +39 -0
  88. package/docs/rules/talk/undeclared.md +35 -0
  89. package/docs/rules/talk/video-format.md +43 -0
  90. package/docs/rules/tex/claim-emphasis.md +71 -0
  91. package/docs/rules/tex/contrast-frames.md +100 -0
  92. package/docs/rules/tex/relation-markers.md +72 -0
  93. package/docs/rules.md +57 -42
  94. package/docs/talk.md +94 -0
  95. package/fixtures/talk/audio-only.mp4 +0 -0
  96. package/fixtures/talk/one-slide.png +0 -0
  97. package/fixtures/talk/short-audio.mp4 +0 -0
  98. package/fixtures/talk/talk.mov +0 -0
  99. package/fixtures/talk/talk.mp4 +0 -0
  100. package/fixtures/talk/talk.srt +7 -0
  101. package/lib/paper-config.harness.mjs +5 -2
  102. package/lib/paper-config.ts +4 -0
  103. package/package.json +3 -1
  104. package/presets/agenticdev.jsonc +29 -0
  105. package/presets/aidc.jsonc +50 -0
  106. package/presets/venue-profile.schema.json +180 -1
  107. package/skills/grade-paper-writing/SKILL.md +59 -27
  108. package/skills/grade-paper-writing/SKILL.md.spec.ts +58 -26
  109. package/src/adapters/latex/index.test.ts +91 -0
  110. package/src/adapters/latex/index.ts +3 -2
  111. package/src/adapters/latex/prose.ts +188 -58
  112. package/src/adapters/mp4box/index.test.ts +63 -0
  113. package/src/adapters/mp4box/index.ts +50 -0
  114. package/src/adapters/node/index.ts +1 -0
  115. package/src/adapters/node/list-dir.io.ts +15 -0
  116. package/src/adapters/node/list-dir.test.ts +30 -0
  117. package/src/adapters/png/index.test.ts +43 -0
  118. package/src/adapters/png/index.ts +29 -0
  119. package/src/adapters/srt/index.test.ts +24 -0
  120. package/src/adapters/srt/index.ts +13 -0
  121. package/src/cli.test.ts +12 -0
  122. package/src/cli.ts +50 -21
  123. package/src/domain/register.test.ts +153 -0
  124. package/src/domain/register.ts +261 -0
  125. package/src/domain/talk.test.ts +125 -0
  126. package/src/domain/talk.ts +274 -0
  127. package/src/domain/tex-document.ts +37 -3
  128. package/src/new-paper.test.ts +1 -0
  129. package/src/paper-settings.test.ts +18 -1
  130. package/src/paper-settings.ts +21 -11
  131. package/src/ports/latex.ts +6 -0
  132. package/src/ports/talk-media.ts +19 -0
  133. package/src/presets.ts +14 -9
  134. package/src/register-bands.test.ts +327 -0
  135. package/src/register-bands.ts +291 -0
  136. package/src/register.ts +7 -37
  137. package/src/rule-cases.test.ts +177 -20
  138. package/src/talk-rules.test.ts +459 -0
  139. package/src/talk-rules.ts +672 -0
  140. package/src/tex-requirements.ts +56 -8
  141. package/src/tex-venue-rules.ts +7 -1
package/src/cli.ts CHANGED
@@ -48,7 +48,16 @@ import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
48
48
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
49
49
  import { curlDownload } from "./adapters/curl/index.ts";
50
50
  import { latexReader } from "./adapters/latex/index.ts";
51
- import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
51
+ import {
52
+ hostDirs,
53
+ nodeAdapters,
54
+ nodeFiles,
55
+ nodeListDir,
56
+ } from "./adapters/node/index.ts";
57
+ import { probeVideo } from "./adapters/mp4box/index.ts";
58
+ import { imageSize } from "./adapters/png/index.ts";
59
+ import { captions } from "./adapters/srt/index.ts";
60
+ import { TALK_RULE_LEVELS, talkRules } from "./talk-rules.ts";
52
61
  import {
53
62
  ANONYMITY_RULE_LEVELS,
54
63
  anonymityRules,
@@ -65,6 +74,10 @@ import {
65
74
  } from "./tex-venue-rules.ts";
66
75
  import { claimProvenanceRule } from "./claim-provenance.ts";
67
76
  import { registerRule } from "./register.ts";
77
+ import {
78
+ REGISTER_BAND_RULE_LEVELS,
79
+ registerBandRules,
80
+ } from "./register-bands.ts";
68
81
  import {
69
82
  paperRules,
70
83
  identityField,
@@ -121,6 +134,7 @@ import {
121
134
  CONFIG_FILE,
122
135
  DEFAULT_PAPERS_ROOT,
123
136
  PAPERS_DIR_FIELD,
137
+ PAPER_ONLY_KEYS,
124
138
  SETTINGS_KEYS,
125
139
  findProjectRoot,
126
140
  } from "#lib/paper-config";
@@ -232,10 +246,26 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
232
246
  own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
233
247
  pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
234
248
  pdf/body-size, pdf/measured, format/page-limit, format/layout-override, anonymity/identity) are
235
- on for every paper with a venue preset; set one to "off" to skip it. An unknown key, in either
236
- file, is an error.
249
+ on for every paper with a venue preset; set one to "off" to skip it. The talk rules (talk/*) judge
250
+ the finished talk files of a paper whose paperlint.json declares "talk" (docs/talk.md). An
251
+ unknown key, in either file, is an error.
237
252
  `;
238
253
 
254
+ /** What the rules over a paper.tex read with: the disk, the shipped presets, the LaTeX reader. */
255
+ const TEX_RULE_DEPS = {
256
+ files: nodeFiles,
257
+ venuesDir: presetsDir(),
258
+ latex: latexReader,
259
+ };
260
+
261
+ /** What the talk rules read with: the disk, the shipped presets, and the three media readers. */
262
+ const TALK_RULE_DEPS = {
263
+ files: nodeFiles,
264
+ venuesDir: presetsDir(),
265
+ media: { probeVideo, imageSize, captions },
266
+ listDir: nodeListDir,
267
+ };
268
+
239
269
  /** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
240
270
  export function buildConfig(
241
271
  opts: PaperlintConfig = {},
@@ -325,30 +355,18 @@ export function buildConfig(
325
355
  languages: { latex: texLanguage },
326
356
  rules: {
327
357
  ...texBuild,
328
- ...texVenueRules({
329
- files: nodeFiles,
330
- venuesDir: presetsDir(),
331
- latex: latexReader,
332
- }),
333
- "claim-provenance": claimProvenanceRule({
334
- files: nodeFiles,
335
- latex: latexReader,
336
- }),
337
- register: registerRule({
338
- files: nodeFiles,
339
- latex: latexReader,
340
- }),
358
+ ...texVenueRules(TEX_RULE_DEPS),
359
+ "claim-provenance": claimProvenanceRule(TEX_RULE_DEPS),
360
+ register: registerRule(TEX_RULE_DEPS),
361
+ ...registerBandRules(TEX_RULE_DEPS),
341
362
  },
342
363
  },
343
364
  paper: { rules: texPaperRules },
344
365
  bib: bibReachable,
366
+ talk: { rules: talkRules(TALK_RULE_DEPS) },
345
367
  format: {
346
368
  rules: {
347
- ...formatRules({
348
- files: nodeFiles,
349
- venuesDir: presetsDir(),
350
- latex: latexReader,
351
- }),
369
+ ...formatRules(TEX_RULE_DEPS),
352
370
  ...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
353
371
  },
354
372
  },
@@ -364,6 +382,9 @@ export function buildConfig(
364
382
  "tex/claim-provenance": "warn",
365
383
  // Sentence-initial conjunctions over the whole body: register, not vocabulary.
366
384
  "tex/register": "warn",
385
+ // Contrast frames, claims in bold, relation markers: each against the band the venue
386
+ // preset's anchors set, silent for a preset without anchors (src/register-bands.ts).
387
+ ...REGISTER_BAND_RULE_LEVELS,
367
388
  "tex/acm-frontmatter-override": "error",
368
389
  // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
369
390
  ...VENUE_RULE_LEVELS,
@@ -371,6 +392,8 @@ export function buildConfig(
371
392
  ...TEX_VENUE_RULE_LEVELS,
372
393
  ...FORMAT_RULE_LEVELS,
373
394
  ...PAGE_LIMIT_RULE_LEVELS,
395
+ // Silent for a paper that declares no `talk` (src/talk-rules.ts), but talk/undeclared.
396
+ ...TALK_RULE_LEVELS,
374
397
  },
375
398
  });
376
399
  // THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
@@ -695,6 +718,12 @@ export function parseSettings(
695
718
  `${where}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} — ` +
696
719
  `a typo would otherwise read as "not set". Known keys: ${Object.keys(SETTINGS_KEYS).join(", ")}`,
697
720
  };
721
+ const paperOnly = PAPER_ONLY_KEYS.find((k) => k in raw);
722
+ if (paperOnly !== undefined)
723
+ return {
724
+ ok: false,
725
+ error: `${where}: "${paperOnly}" is a paper's setting — set it in that paper's ${CONFIG_FILE}`,
726
+ };
698
727
  const defaults = stringFields(raw);
699
728
  if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
700
729
  const identity = identityField(raw);
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The register measures on real parses: what counts as a contrast frame, a relation marker and a
3
+ * claim in bold, and what does not; the band the anchors set; the excerpt a finding quotes.
4
+ * Every "does not count" case below is a sentence shape found in an accepted ACSAC paper.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { latexReader } from "../adapters/latex/index.ts";
8
+ import {
9
+ bandOf,
10
+ bodySentences,
11
+ claimEmphasis,
12
+ contrastFrames,
13
+ excerpt,
14
+ rateOf,
15
+ relationMarkers,
16
+ type RegisterAnchor,
17
+ } from "./register.ts";
18
+
19
+ const doc = (body: string): string =>
20
+ `\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
21
+ const sentencesOf = (body: string) =>
22
+ bodySentences(latexReader.bodyProse(doc(body)));
23
+ const frames = (body: string): number =>
24
+ contrastFrames(sentencesOf(body)).length;
25
+ const markers = (body: string): number =>
26
+ relationMarkers(sentencesOf(body)).length;
27
+ const claims = (body: string): readonly string[] =>
28
+ claimEmphasis(latexReader.bodyEmphasis(doc(body))).map((o) => o.match);
29
+
30
+ describe("contrastFrames — a claim set against a rejected alternative", () => {
31
+ it.each([
32
+ [
33
+ "X, not Y",
34
+ "They react to the dangerous word, not the dangerous operation.",
35
+ ],
36
+ ["X --- not Y", "It guarantees encryption --- not authenticity."],
37
+ ["X; not Y", "We score the operation; not the attacker."],
38
+ ["X, and not Y", "It reads the field, and not the raw input."],
39
+ ["not X but Y", "Over-blocking is not a side metric but a precondition."],
40
+ ["rather than", "It fires on the mention rather than the act."],
41
+ ["instead of", "It reads the whole event instead of the command."],
42
+ ["as opposed to", "It matches words as opposed to operations."],
43
+ ])("🔴 %s is one frame", (_form, sentence) => {
44
+ expect(frames(sentence)).toBe(1);
45
+ });
46
+
47
+ it.each([
48
+ ["not only … but also", "It is not only fast but also exact."],
49
+ [
50
+ "a negated clause",
51
+ "The nature of the audit is not reported, but it did not assess quality.",
52
+ ],
53
+ ["«, not yet»", "The fix is merged, not yet released."],
54
+ [
55
+ "«, not already in the corpus»",
56
+ "We kept every guard published since, not already in the corpus.",
57
+ ],
58
+ ["«without»", "The attack works without any access to the device."],
59
+ ["«unlike»", "Unlike prior work, we measure the installed guards."],
60
+ ["a bare negation", "The guard does not block it."],
61
+ ])("%s is no frame", (_form, sentence) => {
62
+ expect(frames(sentence)).toBe(0);
63
+ });
64
+
65
+ it("two frames in one sentence are two; one phrase matching two forms is one", () => {
66
+ expect(
67
+ frames("It reads the word, not the act, rather than the operation."),
68
+ ).toBe(2);
69
+ expect(frames("It is not a word but rather than a verb.")).toBe(2);
70
+ });
71
+
72
+ it("a frame in a table, a caption or a heading is not prose, so not counted", () => {
73
+ expect(
74
+ frames(
75
+ "\\section{Words, not operations}\n\\begin{table}\\caption{Words, not operations.}\\begin{tabular}{l}a, not b\\end{tabular}\\end{table}\nPlain text here.",
76
+ ),
77
+ ).toBe(0);
78
+ });
79
+ });
80
+
81
+ describe("relationMarkers — words that name how a sentence relates to the one before", () => {
82
+ it("🔴 each marker counts where it stands, sentence-initial or not", () => {
83
+ expect(
84
+ markers(
85
+ "However, the rate is low. It fails because the rule matches words. For example, echo is blocked. It is therefore unsafe.",
86
+ ),
87
+ ).toBe(4);
88
+ });
89
+
90
+ it("a marker inside a longer word is not one: «thusly» is no «thus», «sincere» no «since»", () => {
91
+ expect(
92
+ markers("The sincere author wrote thusly. Eventually it held."),
93
+ ).toBe(0);
94
+ });
95
+ });
96
+
97
+ describe("claimEmphasis — a claim set in bold inside a sentence", () => {
98
+ it("🔴 a bold phrase of six words or more, or holding a number, inside a sentence", () => {
99
+ expect(
100
+ claims(
101
+ "We find that \\textbf{they react to the dangerous word}. The \\textbf{median coverage is 2.5/10} here.",
102
+ ),
103
+ ).toEqual([
104
+ "they react to the dangerous word",
105
+ "median coverage is 2.5/10",
106
+ ]);
107
+ });
108
+
109
+ it("a term in bold, a label opening a paragraph or item, italics, and a run-in heading are not claims", () => {
110
+ expect(
111
+ claims(
112
+ "BaroVox offers a \\textbf{Pressure-Acoustic Transformation (PAT)} here.\n\n\\textbf{Varying the orientation of the sound source from the device}. We vary it.\n\\begin{itemize}\n\\item \\textbf{A corpus of user security questions with sources}: we release it.\n\\end{itemize}\nWe \\emph{find that the guards react to the word}.\n\n\\textbf{Threats to validity of the 46 guards.} None.",
113
+ ),
114
+ ).toEqual([]);
115
+ });
116
+ });
117
+
118
+ describe("bandOf — the anchors' range, widened by two Poisson standard deviations", () => {
119
+ const anchor = (words: number, contrast_frames: number): RegisterAnchor => ({
120
+ paper: "p",
121
+ words,
122
+ counts: { contrast_frames, claim_emphasis: 0, relation_markers: 0 },
123
+ });
124
+
125
+ it("🔴 the edges stand two standard deviations of the edge anchor's count beyond it", () => {
126
+ // 4 in 10,000 words: rate 4, sd 2 → max 8. 1 in 10,000: rate 1, sd 1 → min 1 - 2 < 0 → 0.
127
+ expect(
128
+ bandOf([anchor(10_000, 4), anchor(10_000, 1)], "contrast_frames"),
129
+ ).toEqual({ min: 0, max: 8, anchorMin: 1, anchorMax: 4, anchors: 2 });
130
+ });
131
+
132
+ it("an anchor with no occurrence is widened as if it had one; a floor above zero stays", () => {
133
+ expect(bandOf([anchor(10_000, 0)], "contrast_frames")?.max).toBe(2);
134
+ expect(bandOf([anchor(10_000, 100)], "contrast_frames")?.min).toBe(80);
135
+ });
136
+
137
+ it("no anchors, no band; a body of no words has a rate of zero", () => {
138
+ expect(bandOf([], "contrast_frames")).toBeNull();
139
+ expect(rateOf(3, 0)).toBe(0);
140
+ });
141
+ });
142
+
143
+ describe("excerpt — the words around an occurrence, as a finding quotes them", () => {
144
+ it("the match with its neighbours, cut at a word, an ellipsis where the sentence goes on", () => {
145
+ const s =
146
+ "We score 46 gates and find that they react to the dangerous word, not the dangerous operation, in every one of the ten cases we built.";
147
+ const at = s.indexOf(", not");
148
+ expect(excerpt(s, at, at + 6)).toBe(
149
+ "…that they react to the dangerous word, not the dangerous operation, in every one…",
150
+ );
151
+ expect(excerpt("a, not b", 1, 7)).toBe("a, not b");
152
+ });
153
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * THE REGISTER OF A PAPER'S BODY, COUNTED — the measures the register rules judge, and the band a
3
+ * venue's accepted papers set for each. Pure: passages and emphasised phrases in, counts out.
4
+ *
5
+ * Three measures, each per 10,000 words of the body, each read off the same passages and cut into
6
+ * the same sentences as every other rule over the body (`sentences.ts`):
7
+ *
8
+ * contrast_frames a claim set against a rejected alternative: «X, not Y», «not X but Y»,
9
+ * «X rather than Y», «instead of Y», «as opposed to Y»
10
+ * claim_emphasis a phrase in bold INSIDE a sentence that states something: six words or more,
11
+ * or holding a number — not a label opening a paragraph or an item, and not a
12
+ * term of a few words
13
+ * relation_markers a word that names how a sentence relates to the one before: because,
14
+ * therefore, however, in contrast, for example, that is …
15
+ *
16
+ * ── THE BAND IS THE ANCHORS', NOT A CONSTANT ────────────────────────────────────
17
+ * A venue preset records its anchors — accepted papers of the venue (or its parent conference),
18
+ * each with its word count and its count of every measure, as these counters give them. The band
19
+ * of a measure is the anchors' range, widened at each end by two Poisson standard deviations of
20
+ * the anchor standing there: a paper of that anchor's length and register would fall outside it
21
+ * about one time in forty. An anchor with no occurrence is widened as if it had one. Below zero is
22
+ * zero. So every number a message names comes from the preset's anchors, and changing an anchor
23
+ * changes the band.
24
+ */
25
+ import type { Emphasis, Passage, Span, TextRun } from "./tex-document.ts";
26
+ import { runText, spanIn } from "./tex-document.ts";
27
+ import { CITATION, MATH, REFERENCE, runOf, sentences } from "./sentences.ts";
28
+
29
+ /** The measures a venue's anchors set a band for. */
30
+ export type RegisterMeasureName =
31
+ "contrast_frames" | "claim_emphasis" | "relation_markers";
32
+
33
+ export const REGISTER_MEASURES: readonly RegisterMeasureName[] = [
34
+ "contrast_frames",
35
+ "claim_emphasis",
36
+ "relation_markers",
37
+ ];
38
+
39
+ /** One accepted paper of the venue, as the counters measured it. */
40
+ export interface RegisterAnchor {
41
+ /** What the preset calls it: a directory under `fixtures/accepted-papers/`, or a citation. */
42
+ readonly paper: string;
43
+ readonly words: number;
44
+ readonly counts: Readonly<Record<RegisterMeasureName, number>>;
45
+ }
46
+
47
+ /** How far the band reaches past the anchors: this many Poisson standard deviations. */
48
+ export const NOISE_SDS = 2;
49
+
50
+ /** The words a rate is taken over. */
51
+ export const PER = 10_000;
52
+
53
+ /** A rate per `PER` words; 0 for a body with no words. */
54
+ export const rateOf = (count: number, words: number): number =>
55
+ words === 0 ? 0 : (PER * count) / words;
56
+
57
+ /** A measure's band, and the anchors' own range it was widened from. */
58
+ export interface Band {
59
+ readonly min: number;
60
+ readonly max: number;
61
+ readonly anchorMin: number;
62
+ readonly anchorMax: number;
63
+ readonly anchors: number;
64
+ }
65
+
66
+ /** One anchor's rate of a measure, and how far one standard deviation of its count reaches. */
67
+ const anchorRate = (a: RegisterAnchor, m: RegisterMeasureName) => {
68
+ const count = a.counts[m];
69
+ return {
70
+ rate: rateOf(count, a.words),
71
+ sd: rateOf(Math.sqrt(Math.max(count, 1)), a.words),
72
+ };
73
+ };
74
+
75
+ /** The band the anchors set for a measure; null when there are no anchors. */
76
+ export function bandOf(
77
+ anchors: readonly RegisterAnchor[],
78
+ m: RegisterMeasureName,
79
+ ): Band | null {
80
+ const rates = anchors.map((a) => anchorRate(a, m));
81
+ const low = rates.reduce<(typeof rates)[number] | null>(
82
+ (lo, r) => (lo === null || r.rate < lo.rate ? r : lo),
83
+ null,
84
+ );
85
+ const high = rates.reduce<(typeof rates)[number] | null>(
86
+ (hi, r) => (hi === null || r.rate > hi.rate ? r : hi),
87
+ null,
88
+ );
89
+ if (low === null || high === null) return null;
90
+ return {
91
+ min: Math.max(0, low.rate - NOISE_SDS * low.sd),
92
+ max: high.rate + NOISE_SDS * high.sd,
93
+ anchorMin: low.rate,
94
+ anchorMax: high.rate,
95
+ anchors: anchors.length,
96
+ };
97
+ }
98
+
99
+ // ── sentences, as every register measure reads them ───────────────────────────────
100
+
101
+ /** The characters a mark or math stands as in a sentence's text. */
102
+ const MARK = new RegExp(`[${CITATION}${REFERENCE}${MATH}]`, "gu");
103
+
104
+ const WORDS = new Intl.Segmenter("en", { granularity: "word" });
105
+
106
+ /** A sentence of the body: its text as the reader sees it, its words, its first word, where it stands. */
107
+ export interface BodySentence {
108
+ /** The text with marks and math left out and spaces collapsed, for a message. */
109
+ readonly text: string;
110
+ /** The text with each mark and math as one character, for matching. */
111
+ readonly raw: string;
112
+ /** Its word-like words, and one for each mark or math, as it reads on the page. */
113
+ readonly words: number;
114
+ readonly first: string;
115
+ /** The run it was cut from, and where in that run's text it starts. */
116
+ readonly run: TextRun;
117
+ readonly from: number;
118
+ }
119
+
120
+ /** The body's sentences that hold a word, in order. */
121
+ export function bodySentences(
122
+ passages: readonly Passage[],
123
+ ): readonly BodySentence[] {
124
+ return passages.flatMap((p) => {
125
+ const run = runOf(p);
126
+ const text = runText(run);
127
+ return sentences(text).flatMap(([from, to]) => {
128
+ const raw = text.slice(from, to);
129
+ const words = [...WORDS.segment(raw)].filter((w) => w.isWordLike);
130
+ const [first] = words;
131
+ if (first === undefined) return [];
132
+ return [
133
+ {
134
+ text: raw.replace(MARK, "").replace(/\s+/gu, " ").trim(),
135
+ raw,
136
+ words: words.length + (raw.match(MARK)?.length ?? 0),
137
+ first: first.segment,
138
+ run,
139
+ from,
140
+ },
141
+ ];
142
+ });
143
+ });
144
+ }
145
+
146
+ /** The body's words. */
147
+ export const wordsOf = (ss: readonly BodySentence[]): number =>
148
+ ss.reduce((n, s) => n + s.words, 0);
149
+
150
+ // ── the measures ──────────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * One occurrence of a measure: the words around it as the reader sees them, the sentence it stands
154
+ * in, and where it is.
155
+ */
156
+ export interface Occurrence {
157
+ readonly match: string;
158
+ readonly sentence: string;
159
+ readonly at: Span | null;
160
+ }
161
+
162
+ /** The forms as one pattern: matched left to right, so one phrase is never two occurrences. */
163
+ const anyOf = (forms: readonly RegExp[]): Readonly<RegExp> =>
164
+ new RegExp(forms.map((r) => `(?:${r.source})`).join("|"), "giu");
165
+
166
+ /**
167
+ * «not» after a comma, a dash (`---` as the source spells it) or a semicolon — «the word, not the
168
+ * operation», «--- not authenticity» —
169
+ * unless it opens a phrase that rejects nothing («not only», «not yet», «not already in the corpus»).
170
+ */
171
+ const COMMA_NOT =
172
+ /(?:[,;—–]|--)\s*(?:and\s+|but\s+)?not\s+(?!(?:only|least|yet|always|necessarily|all|even|just|merely|surprisingly|already|to)\b)/giu;
173
+
174
+ /** «not X but Y» inside one clause — no comma, colon or semicolon between — but not «not only … but also». */
175
+ const NOT_BUT = /\bnot\b(?!\s+only\b)[^.;:,]{1,60}?\bbut\b(?!\s+also\b)/giu;
176
+
177
+ /** Every form of a contrast frame, as one pattern over one sentence. */
178
+ const CONTRAST_FRAME = anyOf([
179
+ COMMA_NOT,
180
+ NOT_BUT,
181
+ /\brather\s+than\b/u,
182
+ /\binstead\s+of\b/u,
183
+ /\bas\s+opposed\s+to\b/u,
184
+ ]);
185
+
186
+ /**
187
+ * Words that name the relation between a sentence and the one before it: cause, consequence,
188
+ * contrast, concession, example, restatement. The list is the one the register diagnosis measured
189
+ * (every marker whose meaning does not depend on context), not tuned to this corpus.
190
+ */
191
+ const RELATION_MARKERS =
192
+ /(?<![\p{L}])(?:therefore|thus|hence|consequently|as a result|because|since|this means|this implies|which explains|in turn|that is,|i\.e\.|in contrast|by contrast|however|nevertheless|nonetheless|whereas|in other words|for this reason|it follows|accordingly|specifically,|in particular,|for example|for instance|e\.g\.)(?![\p{L}])/giu;
193
+
194
+ /** How many characters of context an excerpt keeps on each side of a match. */
195
+ const CONTEXT = 40;
196
+
197
+ /**
198
+ * The words around `[from, to)` of a sentence as the reader sees them: the match with up to
199
+ * `CONTEXT` characters on each side, cut at a word, marks and math left out.
200
+ */
201
+ export function excerpt(raw: string, from: number, to: number): string {
202
+ const start = Math.max(0, from - CONTEXT);
203
+ const end = Math.min(raw.length, to + CONTEXT);
204
+ const words = raw
205
+ .slice(start, end)
206
+ .replace(MARK, "")
207
+ .split(/\s+/u)
208
+ .filter((w) => w !== "");
209
+ const inner = words.slice(
210
+ start > 0 ? 1 : 0,
211
+ end < raw.length ? -1 : words.length,
212
+ );
213
+ return `${start > 0 ? "…" : ""}${inner.join(" ")}${end < raw.length ? "…" : ""}`;
214
+ }
215
+
216
+ /** Every match of `pattern` in every sentence, in order, with where it stands. */
217
+ function occurrences(
218
+ ss: readonly BodySentence[],
219
+ pattern: Readonly<RegExp>,
220
+ ): readonly Occurrence[] {
221
+ return ss.flatMap((s) =>
222
+ [...s.raw.matchAll(pattern)].map((m) => ({
223
+ match: excerpt(s.raw, m.index, m.index + m[0].length),
224
+ sentence: s.text,
225
+ at: spanIn(s.run, s.from + m.index, s.from + m.index + m[0].length),
226
+ })),
227
+ );
228
+ }
229
+
230
+ /** The contrast frames of the body's sentences. */
231
+ export const contrastFrames = (
232
+ ss: readonly BodySentence[],
233
+ ): readonly Occurrence[] => occurrences(ss, CONTRAST_FRAME);
234
+
235
+ /** The relation markers of the body's sentences. */
236
+ export const relationMarkers = (
237
+ ss: readonly BodySentence[],
238
+ ): readonly Occurrence[] => occurrences(ss, RELATION_MARKERS);
239
+
240
+ /** A phrase this long or longer states something rather than names it. */
241
+ export const CLAIM_WORDS = 6;
242
+
243
+ const wordCount = (s: string): number =>
244
+ [...WORDS.segment(s)].filter((w) => w.isWordLike).length;
245
+
246
+ /**
247
+ * Whether an emphasised phrase is a claim set in bold: bold, inside its sentence (not a label that
248
+ * opens a paragraph or an item), and either `CLAIM_WORDS` words long or holding a number.
249
+ */
250
+ export const isClaimEmphasis = (e: Emphasis): boolean =>
251
+ e.style === "bold" &&
252
+ e.place === "inline" &&
253
+ (wordCount(e.text) >= CLAIM_WORDS || /\p{N}/u.test(e.text));
254
+
255
+ /** The claims the body sets in bold. */
256
+ export const claimEmphasis = (es: readonly Emphasis[]): readonly Occurrence[] =>
257
+ es.filter(isClaimEmphasis).map((e) => ({
258
+ match: e.text,
259
+ sentence: e.text,
260
+ at: e.span,
261
+ }));
@@ -0,0 +1,125 @@
1
+ /** A paper's `talk` key and a preset's `talk` block, parsed. */
2
+ import { describe, expect, it } from "vitest";
3
+ import { DEFAULT_TALK_FILES, parsePaperTalk, venueTalkOf } from "./talk.ts";
4
+
5
+ describe("parsePaperTalk", () => {
6
+ it("is null when the paper declares no talk", () => {
7
+ expect(parsePaperTalk(undefined)).toEqual({ ok: true, value: null });
8
+ });
9
+
10
+ it("defaults the folder and the file names", () => {
11
+ expect(parsePaperTalk({ mode: "remote-video" })).toEqual({
12
+ ok: true,
13
+ value: {
14
+ mode: "remote-video",
15
+ dir: "talk",
16
+ files: DEFAULT_TALK_FILES,
17
+ oneSlide: null,
18
+ },
19
+ });
20
+ });
21
+
22
+ it("takes a folder, file names and a one-slide size", () => {
23
+ const r = parsePaperTalk({
24
+ mode: "in-person",
25
+ dir: "video",
26
+ files: { video: "my-talk.mp4" },
27
+ one_slide: { width_px: 1920, height_px: 1080 },
28
+ });
29
+ expect(r).toEqual({
30
+ ok: true,
31
+ value: {
32
+ mode: "in-person",
33
+ dir: "video",
34
+ files: { ...DEFAULT_TALK_FILES, video: "my-talk.mp4" },
35
+ oneSlide: { width: 1920, height: 1080 },
36
+ },
37
+ });
38
+ });
39
+ });
40
+
41
+ describe("parsePaperTalk refuses", () => {
42
+ it.each([
43
+ ["not an object", "remote-video", '"talk" must be an object'],
44
+ [
45
+ "an unknown key",
46
+ { mode: "remote-video", lenght: 1 },
47
+ 'unknown key "lenght"',
48
+ ],
49
+ ["an unknown mode", { mode: "zoom" }, '"talk.mode" must be one of'],
50
+ ["an empty folder", { mode: "remote-video", dir: "" }, '"talk.dir"'],
51
+ [
52
+ "files not an object",
53
+ { mode: "remote-video", files: [] },
54
+ '"talk.files" must be an object',
55
+ ],
56
+ [
57
+ "an unknown artifact",
58
+ { mode: "remote-video", files: { slides: "s.pdf" } },
59
+ '"talk.files": "slides"',
60
+ ],
61
+ [
62
+ "a path for a name",
63
+ { mode: "remote-video", files: { video: "a/b.mp4" } },
64
+ '"talk.files": "video"',
65
+ ],
66
+ [
67
+ "a size that is not integers",
68
+ { mode: "remote-video", one_slide: { width_px: 1.5, height_px: 2 } },
69
+ '"talk.one_slide"',
70
+ ],
71
+ ])("%s", (_, v, msg) => {
72
+ const r = parsePaperTalk(v);
73
+ expect(r.ok).toBe(false);
74
+ expect(r.ok ? "" : r.error).toContain(msg);
75
+ });
76
+ });
77
+
78
+ describe("venueTalkOf", () => {
79
+ it("types every field, absent ones as null or empty", () => {
80
+ expect(venueTalkOf({})).toEqual({
81
+ modes: [],
82
+ artifacts: new Map(),
83
+ kinds: new Map(),
84
+ video: {
85
+ container: null,
86
+ minHeightPx: null,
87
+ maxBytes: null,
88
+ captions: null,
89
+ },
90
+ oneSlide: null,
91
+ });
92
+ });
93
+
94
+ it("reads modes, artifacts per mode, slots per kind, video limits and the one-slide size", () => {
95
+ const t = venueTalkOf({
96
+ modes: ["remote-video"],
97
+ artifacts: { "remote-video": { required: ["video"] } },
98
+ kinds: { short: { slot_s: 600, talk_s_max: 420 } },
99
+ video: {
100
+ container: "mp4",
101
+ min_height_px: 720,
102
+ max_bytes: 9,
103
+ captions: "required",
104
+ },
105
+ one_slide: { width_px: 1920, height_px: 1080 },
106
+ });
107
+ expect(t.artifacts.get("remote-video")).toEqual({
108
+ required: ["video"],
109
+ optional: [],
110
+ });
111
+ expect(t.kinds.get("short")).toEqual({
112
+ slotS: 600,
113
+ talkSMin: null,
114
+ talkSMax: 420,
115
+ qaS: null,
116
+ });
117
+ expect(t.video).toEqual({
118
+ container: "mp4",
119
+ minHeightPx: 720,
120
+ maxBytes: 9,
121
+ captions: "required",
122
+ });
123
+ expect(t.oneSlide).toEqual({ width: 1920, height: 1080 });
124
+ });
125
+ });