unknown-knowledge 2.1.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 (147) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +167 -0
  4. package/cli/.gitkeep +0 -0
  5. package/cli/commands/init-copy.js +90 -0
  6. package/cli/commands/init.js +386 -0
  7. package/cli/init-copy.js +24 -0
  8. package/cli/init.js +24 -0
  9. package/cli/kit.manifest.yaml +256 -0
  10. package/cli/lib/copy-payload.js +334 -0
  11. package/cli/lib/generate-wrappers.js +162 -0
  12. package/package.json +36 -0
  13. package/payload/adapter-fixtures/README.md +53 -0
  14. package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
  15. package/payload/adapter-fixtures/html/sample.html +38 -0
  16. package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
  17. package/payload/adapter-fixtures/md/sample.md +32 -0
  18. package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
  19. package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
  20. package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
  21. package/payload/adapter-fixtures/txt/sample.txt +18 -0
  22. package/payload/docs/README.md +102 -0
  23. package/payload/docs/boundaries.md +60 -0
  24. package/payload/docs/ci-wiring.md +109 -0
  25. package/payload/docs/steward-guide.md +238 -0
  26. package/payload/engine/audit.js +34 -0
  27. package/payload/engine/commands/audit.js +289 -0
  28. package/payload/engine/commands/derive.js +334 -0
  29. package/payload/engine/commands/ingest.js +124 -0
  30. package/payload/engine/commands/log-entry.js +85 -0
  31. package/payload/engine/commands/phoenix.js +206 -0
  32. package/payload/engine/commands/preflight.js +530 -0
  33. package/payload/engine/commands/resolve.js +1678 -0
  34. package/payload/engine/commands/survey-map.js +351 -0
  35. package/payload/engine/commands/validate-values.js +315 -0
  36. package/payload/engine/commands/validate.js +1426 -0
  37. package/payload/engine/derive.js +34 -0
  38. package/payload/engine/ingest.js +34 -0
  39. package/payload/engine/lib/anchor-signatures.js +126 -0
  40. package/payload/engine/lib/boot.js +39 -0
  41. package/payload/engine/lib/call-numbers.js +133 -0
  42. package/payload/engine/lib/cli.js +147 -0
  43. package/payload/engine/lib/coverage.js +849 -0
  44. package/payload/engine/lib/decomposition.js +225 -0
  45. package/payload/engine/lib/derived.js +494 -0
  46. package/payload/engine/lib/engine-refusal.js +40 -0
  47. package/payload/engine/lib/exit-codes.js +14 -0
  48. package/payload/engine/lib/extractor-kinds.js +955 -0
  49. package/payload/engine/lib/format-adapters.js +802 -0
  50. package/payload/engine/lib/id-grammars.js +178 -0
  51. package/payload/engine/lib/iso-date.js +55 -0
  52. package/payload/engine/lib/kit-root.js +101 -0
  53. package/payload/engine/lib/load-stores.js +1624 -0
  54. package/payload/engine/lib/log-entry.js +196 -0
  55. package/payload/engine/lib/phoenix.js +628 -0
  56. package/payload/engine/lib/scoring.js +150 -0
  57. package/payload/engine/lib/suppressions.js +172 -0
  58. package/payload/engine/lib/time-verdicts.js +282 -0
  59. package/payload/engine/lib/usage-error.js +14 -0
  60. package/payload/engine/lib/validate-record.js +504 -0
  61. package/payload/engine/log-entry.js +34 -0
  62. package/payload/engine/phoenix.js +39 -0
  63. package/payload/engine/preflight.js +34 -0
  64. package/payload/engine/resolve.js +34 -0
  65. package/payload/engine/survey-map.js +34 -0
  66. package/payload/engine/validate-values.js +34 -0
  67. package/payload/engine/validate.js +34 -0
  68. package/payload/extractor-fixtures/.gitkeep +0 -0
  69. package/payload/extractor-fixtures/README.md +29 -0
  70. package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
  71. package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
  72. package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
  73. package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
  74. package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
  75. package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
  76. package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
  77. package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
  78. package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
  79. package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
  80. package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
  81. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
  82. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
  83. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
  84. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
  85. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
  86. package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
  87. package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
  88. package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
  89. package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
  90. package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
  91. package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
  92. package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
  93. package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
  94. package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
  95. package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
  96. package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
  97. package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
  98. package/payload/hooks/pre-commit +37 -0
  99. package/payload/hooks/reverse-lookup +66 -0
  100. package/payload/package.json +3 -0
  101. package/payload/protocol/.gitkeep +0 -0
  102. package/payload/protocol/AGENTS.md +239 -0
  103. package/payload/protocol/derived-layer.md +174 -0
  104. package/payload/protocol/new-kind-pipeline.md +179 -0
  105. package/payload/protocol/registry-warrant.md +162 -0
  106. package/payload/protocol/skills/kb-build.md +303 -0
  107. package/payload/protocol/skills/knowledge-audit.md +183 -0
  108. package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
  109. package/payload/protocol/skills/knowledge-reflect.md +397 -0
  110. package/payload/schemas/catalog.schema.json +32 -0
  111. package/payload/schemas/decision-entry.schema.json +122 -0
  112. package/payload/schemas/finding.schema.json +77 -0
  113. package/payload/schemas/gap.schema.json +52 -0
  114. package/payload/schemas/graduation-categories.schema.json +64 -0
  115. package/payload/schemas/knowledge-leaf.schema.json +194 -0
  116. package/payload/schemas/miss.schema.json +45 -0
  117. package/payload/schemas/ontology-concept.schema.json +115 -0
  118. package/payload/schemas/phoenix-event.schema.json +76 -0
  119. package/payload/schemas/registry.schema.json +57 -0
  120. package/payload/schemas/rules.schema.json +14 -0
  121. package/payload/schemas/survey-scope.schema.json +23 -0
  122. package/payload/templates/decisions/_catalog.yaml +7 -0
  123. package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
  124. package/payload/templates/decisions/phoenix-event.yaml +74 -0
  125. package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
  126. package/payload/templates/decisions/registry-minting.yaml +58 -0
  127. package/payload/templates/decisions/trust-graduation.yaml +120 -0
  128. package/payload/templates/decisions/trust-revocation.yaml +106 -0
  129. package/payload/templates/knowledge/_catalog.yaml +9 -0
  130. package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
  131. package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
  132. package/payload/templates/knowledge/_registries/domains.yaml +43 -0
  133. package/payload/templates/knowledge/_registries/form.yaml +38 -0
  134. package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
  135. package/payload/templates/knowledge/_registries/operations.yaml +18 -0
  136. package/payload/templates/knowledge/_registries/stage.yaml +53 -0
  137. package/payload/templates/knowledge/_rules.yaml +6 -0
  138. package/payload/templates/new-kind/README.md +107 -0
  139. package/payload/templates/new-kind/descriptor.example.yaml +18 -0
  140. package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
  141. package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
  142. package/payload/templates/new-kind/fixture/sample.list +7 -0
  143. package/payload/templates/new-kind/parser.example.js +98 -0
  144. package/payload/templates/ontology/_catalog.yaml +6 -0
  145. package/payload/templates/ontology/_rules.yaml +6 -0
  146. package/payload/wrappers/cursor.mdc +15 -0
  147. package/payload/wrappers/pointer.md +10 -0
@@ -0,0 +1,849 @@
1
+ /**
2
+ * Document coverage map (UCS-1156) — a document-sized request made
3
+ * processable, at a context cost that grows with CONTENT RICHNESS rather than
4
+ * with document length.
5
+ *
6
+ * The problem this solves is not "search a big file". It is that an agent
7
+ * handed a 40-page PRD has two bad options: read all of it (the context cost
8
+ * is the document, and most of it is irrelevant) or read none of it and guess.
9
+ * The coverage map is the third option — a bounded artifact that says WHICH
10
+ * governed knowledge the document joined, WHERE in the document each join
11
+ * happened, and WHAT vocabulary the document used that the store does not yet
12
+ * govern. The agent's cost becomes the map plus the sections it chooses to
13
+ * open, and a redundant 40-page document produces a SMALLER map than a dense
14
+ * two-page one because repetition adds no new joins.
15
+ *
16
+ * SIZE- AND FORMAT-INVARIANCE is the load-bearing claim. One entry point takes
17
+ * three input shapes — a query, repo paths, a document — and a query is
18
+ * processed as a ONE-BLOCK DOCUMENT through this same code. That is not a
19
+ * convenience: it is what makes the equivalence testable. If a query and its
20
+ * equivalent one-block document produced different joins, then "the pipeline
21
+ * is size-invariant" would be a slogan rather than a property, and the
22
+ * document path would be free to drift into a second, weaker matcher that
23
+ * nobody compares against the real one.
24
+ *
25
+ * A SECTION'S JOIN IS THE QUERY PIPELINE, RUN OVER THAT SECTION. Nothing here
26
+ * reimplements matching. `sectionJoins` calls the same `decompose`/`scoreLeaves`/
27
+ * `applyScope`/`rankLeaves` functions the resolver's query mode calls, over the
28
+ * section's own tokens. A second matcher tuned for documents is exactly how two
29
+ * surfaces come to disagree about what a store contains, and the disagreement
30
+ * would be invisible: both would return plausible results.
31
+ *
32
+ * SALIENCE IS PINNED, NOT LEARNED. The candidate extractor uses three fixed
33
+ * signatures and subtracts three fixed vocabularies. No tf-idf, no corpus
34
+ * statistics, no thresholds tuned against a sample: a candidate list that
35
+ * changed because the store grew would make "this document introduced new
36
+ * vocabulary" unreproducible, and the candidates are the input to a MINTING
37
+ * decision (literary warrant, §3.5). A steward reading a candidate must be able
38
+ * to re-derive it from the document alone.
39
+ *
40
+ * WHAT THIS MODULE DOES NOT DO. It does not log submissions, and it does not
41
+ * keep a registry of documents it has seen. The idempotence criterion is met by
42
+ * the content hash the adapter already computes travelling into the map, so two
43
+ * byte-identical submissions produce byte-identical maps carrying the same
44
+ * hash — dedupe is a property a consumer can check, not a stateful service this
45
+ * engine runs. A submissions log would be a second source of truth about what
46
+ * was ingested, and D-012 wants the output to be a pure function of the input.
47
+ */
48
+ import { compare } from './validate-record.js';
49
+ import { STOPWORDS, tokenize } from './decomposition.js';
50
+ import { suppressibleBy } from './suppressions.js';
51
+
52
+ /**
53
+ * SECTIONS
54
+ *
55
+ * A section is a heading block and everything under it until the next heading
56
+ * at the same or a shallower level. Sections are derived from the IR, never
57
+ * from the source bytes: the adapter already decided what a heading is, and a
58
+ * second opinion here would let the map address a structure the IR does not
59
+ * have.
60
+ *
61
+ * PREAMBLE. Content before the first heading is its own section, addressed
62
+ * `(preamble)`. Dropping it would lose the abstract of every document that
63
+ * opens with one, and merging it into the first heading's section would report
64
+ * joins under an address whose text does not cover them — a locator that sends
65
+ * a reader to the wrong lines is worse than no locator.
66
+ *
67
+ * A FORMAT WITH NO HEADINGS (txt@1 emits only paragraphs) yields exactly one
68
+ * section covering the whole document. That is the honest answer: the map's
69
+ * structure degrades with the input's, and the process is identical. It is not
70
+ * a failure and it is not padded out with invented windows — inventing section
71
+ * boundaries the document does not have would address a reader to a "section"
72
+ * no editor shows.
73
+ */
74
+ export const PREAMBLE_ADDRESS = '(preamble)';
75
+
76
+ /**
77
+ * How many section addresses any one list enumerates before it summarizes.
78
+ *
79
+ * A LIST OF ADDRESSES IS THE ONE PART OF THE MAP THAT GROWS WITH LENGTH. A term
80
+ * repeated in every section of a forty-section document would otherwise
81
+ * enumerate forty addresses, and a handful of such terms is a map that grows
82
+ * linearly with the document — the exact property this ticket exists to deny.
83
+ * Everything else in the map is bounded by richness already: joins are bounded
84
+ * by the store's vocabulary, candidates by how much new language the document
85
+ * introduces, and repeated sections fold.
86
+ *
87
+ * So an address list is capped, and the overflow is reported as a COUNT rather
88
+ * than dropped: `sections: [...8 addresses], "sections-more": 31` says "this
89
+ * term is everywhere" in constant space, which is the fact a reader needs. Eight
90
+ * is enough to see a pattern (a term in eight named sections is localized; one
91
+ * that overflows is pervasive) and small enough that a hundred candidates
92
+ * cannot make the map unbounded.
93
+ *
94
+ * Addresses are sorted before capping, so WHICH eight survive is deterministic
95
+ * rather than dependent on document order.
96
+ */
97
+ export const ADDRESS_CAP = 8;
98
+
99
+ /**
100
+ * Cap an address list, reporting the overflow as a count.
101
+ *
102
+ * @param {string[]} addresses sorted section addresses
103
+ * @returns {{sections: string[], more: number}}
104
+ */
105
+ const capAddresses = (addresses) => ({
106
+ sections: addresses.slice(0, ADDRESS_CAP),
107
+ more: Math.max(0, addresses.length - ADDRESS_CAP),
108
+ });
109
+
110
+ /**
111
+ * The UNCAPPED set of sections a candidate appeared in, carried on the
112
+ * published candidate for internal membership tests.
113
+ *
114
+ * A Symbol, for the same reason `SUPPRESSION_IDENTITY` is one: `JSON.stringify`
115
+ * and `Object.keys` skip symbol keys, so it cannot reach the wire, and the
116
+ * map's published shape is a contract. It exists because CAPPING IS A DISPLAY
117
+ * DECISION and the rest of the pipeline must never read the capped list — see
118
+ * `rankCandidates` for what breaks when it does.
119
+ */
120
+ const IN_SECTIONS = Symbol('uncapped candidate sections');
121
+
122
+ /**
123
+ * Group IR blocks into sections.
124
+ *
125
+ * The address is the heading TEXT, and the locator is the line range — together
126
+ * they are what makes a just-in-time read possible: `sed -n '12,40p' doc.md`
127
+ * needs the range, and a human deciding whether to open it needs the text.
128
+ *
129
+ * Heading level opens and closes a section: a deeper heading (`##` under `#`)
130
+ * NESTS, and its content also belongs to the shallower section. This module
131
+ * emits sections at every heading, and a nested section's lines are a subrange
132
+ * of its parent's — that overlap is deliberate. The alternative (flat, sibling
133
+ * sections) would make an `##` subsection's joins invisible in the `#` section
134
+ * that a reader would actually open, and the whole point of the locator is that
135
+ * opening it shows the content the map attributed to it.
136
+ *
137
+ * The end line comes from the LAST BLOCK in the section, not from the next
138
+ * heading's start: trailing blank lines belong to neither, and a range that
139
+ * included them would claim coverage of lines the IR never read.
140
+ *
141
+ * @param {object[]} blocks the IR's ordered blocks
142
+ * @returns {Array<{address, level, line, endLine, blocks}>}
143
+ */
144
+ export function sectionsOf(blocks) {
145
+ const sections = [];
146
+ /** Open sections, shallowest first — a block belongs to every one of them. */
147
+ const open = [];
148
+ let preamble = null;
149
+
150
+ const extend = (section, block) => {
151
+ const { line, endLine, page } = block.locator;
152
+ // A pdf locator is {page, object} rather than {line, endLine}. Its blocks
153
+ // still group into sections; the RANGE is what the format can express, and
154
+ // for pdf that is the page. Publishing `line: null` rather than inventing a
155
+ // line number keeps the locator honest about the coordinates the source has.
156
+ section.blocks.push(block);
157
+ if (page !== undefined) {
158
+ section.page ??= page;
159
+ section.endPage = page;
160
+ return;
161
+ }
162
+ section.line = Math.min(section.line ?? line, line);
163
+ section.endLine = Math.max(section.endLine ?? endLine, endLine ?? line);
164
+ };
165
+
166
+ for (const block of blocks) {
167
+ if (block.kind === 'heading') {
168
+ const level = typeof block.level === 'number' ? block.level : 1;
169
+ // Close every section this heading is not nested inside.
170
+ while (open.length && open[open.length - 1].level >= level) open.pop();
171
+ const section = {
172
+ address: block.text,
173
+ level,
174
+ line: null,
175
+ endLine: null,
176
+ blocks: [],
177
+ };
178
+ extend(section, block);
179
+ sections.push(section);
180
+ // The heading line belongs to every enclosing section too, so an outer
181
+ // section's range covers its subsections.
182
+ for (const ancestor of open) extend(ancestor, block);
183
+ open.push(section);
184
+ continue;
185
+ }
186
+ if (!open.length) {
187
+ // Content before the first heading. Its own section, never merged.
188
+ preamble ??= { address: PREAMBLE_ADDRESS, level: 0, line: null, endLine: null, blocks: [] };
189
+ extend(preamble, block);
190
+ continue;
191
+ }
192
+ for (const section of open) extend(section, block);
193
+ }
194
+
195
+ return preamble ? [preamble, ...sections] : sections;
196
+ }
197
+
198
+ /**
199
+ * The text a section's lexicon scan reads.
200
+ *
201
+ * CODE BLOCKS ARE EXCLUDED. A fenced block is content no lexicon should
202
+ * tokenize as prose (the IR kinds exist to make exactly this distinction):
203
+ * a code sample naming a variable `token` is not the document discussing
204
+ * tokens, and counting it would join governed vocabulary to a symbol that
205
+ * happens to share a name. The block is still IN the section — its lines are
206
+ * inside the locator's range, so a reader who opens the section sees it.
207
+ */
208
+ const scannableText = (section) => section.blocks
209
+ .filter((b) => b.kind !== 'code')
210
+ .map((b) => b.text)
211
+ .join('\n');
212
+
213
+ /**
214
+ * Attribute each block to exactly ONE section — the innermost that contains it.
215
+ *
216
+ * Sections NEST (a `##` subsection's blocks belong to its `#` parent too), which
217
+ * is right for joins and locators: an agent opening the parent's line range
218
+ * genuinely sees the child's content, and a leaf reached from a subsection is
219
+ * reached from the section containing it.
220
+ *
221
+ * It is WRONG for counting. A salience count is evidence about how often the
222
+ * author said something, so a phrase inside a nested section would be counted
223
+ * once per enclosing level — a `###` under a `##` under a `#` would triple its
224
+ * own count and cross the repetition threshold on nesting depth alone. The
225
+ * document's structure would decide its vocabulary, which is exactly the kind
226
+ * of unreproducible signal the pinned-salience rule exists to forbid.
227
+ *
228
+ * So counting runs over this partition (each block once, attributed to the
229
+ * deepest section that holds it) while joins and locators keep using the nested
230
+ * view. Two readings of one structure, each used where it is the honest one.
231
+ *
232
+ * @param {Array} sections from `sectionsOf`
233
+ * @returns {Array<{address: string, text: string}>} one entry per section, disjoint text
234
+ */
235
+ function ownBlocks(sections) {
236
+ // KEYED BY SECTION OBJECT, NEVER BY ADDRESS. Two sections can carry the SAME
237
+ // heading text — "## Details" under two different parents is ordinary
238
+ // authoring — and a string key silently merges them: their prose pools into
239
+ // one bucket, so counts that neither section reaches alone clear the
240
+ // repetition threshold together, and the second section's own text is
241
+ // attributed to the first. A section is identified by which section it IS;
242
+ // the address is a LABEL for the reader, and a label was never an identity.
243
+ const owner = new Map();
244
+ for (const section of sections) {
245
+ for (const block of section.blocks) owner.set(block, section);
246
+ }
247
+ const bySection = new Map(sections.map((s) => [s, []]));
248
+ for (const section of sections) {
249
+ for (const block of section.blocks) {
250
+ if (owner.get(block) === section && block.kind !== 'code') {
251
+ bySection.get(section).push(block.text);
252
+ }
253
+ }
254
+ }
255
+ return sections.map((s) => ({ section: s, address: s.address, text: bySection.get(s).join('\n') }));
256
+ }
257
+
258
+ /**
259
+ * SALIENCE — the pinned signatures.
260
+ *
261
+ * Three signatures, each a different kind of evidence that the AUTHOR treated
262
+ * a phrase as load-bearing, and each demonstrable from a document alone:
263
+ *
264
+ * emphasis the author marked it up (`**bold**`, `*italic*`). The strongest
265
+ * signal available, because it is an explicit authoring act.
266
+ * title-case a multi-word Title-Case phrase is how English marks a proper
267
+ * noun or a coined term ("Mutual Exclusivity Window").
268
+ * repetition a term repeated at least THRESHOLD times, where the threshold
269
+ * is a step function of document size (see REPETITION_STEPS).
270
+ *
271
+ * Single words are rejected for the first two signatures and accepted only via
272
+ * repetition. A one-word emphasis is usually stress ("this is **not** true"),
273
+ * and minting vocabulary off it would fill the candidate list with adverbs.
274
+ */
275
+
276
+ /**
277
+ * Emphasis spans, read straight out of the IR.
278
+ *
279
+ * NO ADAPTER CHANGE WAS NEEDED, and that is worth stating because the obvious
280
+ * assumption is the opposite. `md@1`'s `flatten()` collapses WHITESPACE only —
281
+ * it never strips inline markup — so `**Mutual Exclusivity**` arrives in the
282
+ * block text with its markers intact. Extending the adapter to tag emphasis
283
+ * would have forced a version bump (a changed IR for the same input, per the
284
+ * documented bump rule) and invalidated every existing fixture pair, to
285
+ * recover information the IR already carries.
286
+ *
287
+ * The consequence, stated rather than hidden: a `txt` or `pdf` document has no
288
+ * emphasis signature, because those formats have no emphasis to mark. Their
289
+ * candidates come from the other two signatures. That is the same graceful
290
+ * degradation the sectioning has — richer structure yields a richer map, and
291
+ * no format is handed invented signal.
292
+ */
293
+ const EMPHASIS = /(\*\*|__)(?=\S)(.+?)(?<=\S)\1|(?<![\w*])(\*|_)(?=\S)([^*_]+?)(?<=\S)\3(?![\w*])/g;
294
+
295
+ /**
296
+ * A multi-word Title-Case phrase: two or more capitalized words in a row.
297
+ *
298
+ * Deliberately conservative. Words joined by an internal lowercase connector
299
+ * ("Bank of England") are NOT matched, because relaxing that far also matches
300
+ * an ordinary sentence opening followed by a proper noun ("The Figma rule"),
301
+ * and a candidate list padded with sentence openings is one a steward stops
302
+ * reading. Under-matching leaves a term uncaptured, which the repetition
303
+ * signature usually catches anyway; over-matching poisons the whole list.
304
+ */
305
+ const TITLE_CASE = /\b([A-Z][a-z]+(?:[ -][A-Z][a-z]+)+)\b/g;
306
+
307
+ /**
308
+ * Determiners stripped from the FRONT of a Title-Case phrase.
309
+ *
310
+ * A sentence that opens "The Quiet Period is unresolved" capitalizes `The` for
311
+ * position, not because it is part of the term. Left in, the same term splits
312
+ * into two candidates — "quiet period" and "the quiet period" — which is worse
313
+ * than either error it replaces: the count that measures salience is halved
314
+ * across the pair, and a steward is asked to mint a phrase with an article
315
+ * bolted on. Stripped only at the START, and only when something survives:
316
+ * "The Hague" is a term whose article is load-bearing, and a phrase that IS a
317
+ * determiner is not a candidate at all.
318
+ */
319
+ const LEADING_DETERMINERS = new Set(['the', 'a', 'an', 'this', 'that', 'these', 'those']);
320
+
321
+ /** Drop a leading determiner from a folded phrase, if one survives it. */
322
+ function stripDeterminer(folded) {
323
+ const words = folded.split(' ');
324
+ if (words.length > 1 && LEADING_DETERMINERS.has(words[0])) return words.slice(1).join(' ');
325
+ return folded;
326
+ }
327
+
328
+ /**
329
+ * The repetition threshold, as a PINNED STEP FUNCTION of document size.
330
+ *
331
+ * A fixed count cannot work across sizes: three occurrences in a one-page brief
332
+ * is the author hammering a point, while three in a forty-page PRD is noise. So
333
+ * the threshold steps up with the document's block count — the IR's own measure
334
+ * of size, which is format-independent in a way that byte length and line count
335
+ * are not (a pdf has no lines; an html file's bytes are mostly markup).
336
+ *
337
+ * A STEP FUNCTION rather than a formula, and spelled as a table, because the
338
+ * candidates feed a minting decision: a steward must be able to say "this
339
+ * document has 60 blocks, so the threshold was 4" by reading one table, without
340
+ * evaluating an expression. Continuous scaling would also make the threshold
341
+ * change on almost every edit, so a candidate could appear and vanish between
342
+ * two near-identical drafts for reasons nobody could see.
343
+ *
344
+ * The steps are the prototype's floor (≥4 doc-wide, its documents sitting in
345
+ * the 24–120 block range) extended in both directions: lower for documents too
346
+ * small for 4 to ever fire, higher for documents long enough that 4 is noise.
347
+ *
348
+ * Read as: the FIRST row whose `maxBlocks` the document does not exceed wins.
349
+ *
350
+ * @type {ReadonlyArray<{maxBlocks: number, threshold: number}>}
351
+ */
352
+ export const REPETITION_STEPS = Object.freeze([
353
+ Object.freeze({ maxBlocks: 8, threshold: 2 }),
354
+ Object.freeze({ maxBlocks: 24, threshold: 3 }),
355
+ Object.freeze({ maxBlocks: 120, threshold: 4 }),
356
+ Object.freeze({ maxBlocks: 400, threshold: 6 }),
357
+ Object.freeze({ maxBlocks: Infinity, threshold: 8 }),
358
+ ]);
359
+
360
+ /**
361
+ * The repetition threshold for a document of this many blocks.
362
+ *
363
+ * @param {number} blocks the IR's block count
364
+ * @returns {number} occurrences required, doc-wide
365
+ */
366
+ export const repetitionThreshold = (blocks) =>
367
+ REPETITION_STEPS.find((step) => blocks <= step.maxBlocks).threshold;
368
+
369
+ /**
370
+ * CONCENTRATION — repetition must mean emphasis, not merely length.
371
+ *
372
+ * A raw count is not enough, and a long redundant document is what proves it.
373
+ * A weekly status report that says "settlement ran clean, nothing required
374
+ * manual correction" in twenty-four near-identical sections puts `manual`,
375
+ * `clean`, and `nothing` over any doc-wide threshold — not because the author
376
+ * kept returning to them, but because the document kept running. Minting
377
+ * vocabulary from that would fill a steward's queue with the boilerplate of
378
+ * whichever team writes the longest reports.
379
+ *
380
+ * So a repeated term must also be CONCENTRATED: it must occur more often than
381
+ * once per section it appears in. A term the author genuinely leaned on comes
382
+ * up several times where it matters ("the Quiet Period ... the Quiet Period
383
+ * rule"), while boilerplate appears exactly once in each of many sections. The
384
+ * test is `count > sections`, which is the weakest form of that claim and the
385
+ * only one that needs no tuning — it asks whether ANY section used the term
386
+ * twice, and a term that never did is spread, not stressed.
387
+ *
388
+ * This is what makes the map grow with richness rather than length: adding
389
+ * another identical week to the report raises a boilerplate term's count and
390
+ * its section count together, so it never becomes concentrated no matter how
391
+ * long the document gets.
392
+ */
393
+ const isConcentrated = (count, sections) => count > sections;
394
+
395
+ /**
396
+ * The naive singular fold, matching `sameWord` in lib/decomposition.js.
397
+ *
398
+ * Candidates are counted in the SAME morphology the joins use, so "tokens" and
399
+ * "token" are one candidate exactly as they are one join. A candidate list that
400
+ * folded differently from the matcher would report a term as unknown while the
401
+ * matcher was already joining it.
402
+ *
403
+ * `ss` is the one exception, and it is here because the candidates are READ BY
404
+ * A HUMAN in a way the matcher's tokens are not. Trailing-s stripping turns
405
+ * "basis" into "basi" and "status" into "statu", so a steward deciding whether
406
+ * to mint "Design Basis Register" would be shown "design basi register" — a
407
+ * misspelling of the thing they are being asked to approve. The matcher can
408
+ * afford the mangling because both sides of a comparison fold identically and
409
+ * nobody reads the result; a minting proposal cannot. Words ending in `ss` are
410
+ * never plurals of a word ending in `s`, so declining to strip them loses no
411
+ * folding that was ever correct.
412
+ */
413
+ const singular = (word) => (word.length > 3 && word.endsWith('s') && !word.endsWith('ss')
414
+ ? word.slice(0, -1)
415
+ : word);
416
+
417
+ /** Fold a phrase to its canonical, comparable form. */
418
+ const foldPhrase = (phrase) => String(phrase)
419
+ .toLowerCase()
420
+ .split(/[\s-]+/)
421
+ .filter(Boolean)
422
+ .map(singular)
423
+ .join(' ');
424
+
425
+ /**
426
+ * KNOWN VOCABULARY — every word of every governed vocabulary in the store.
427
+ *
428
+ * This is the "known" filter the ticket demands be subtracted from candidates,
429
+ * and it is deliberately WORD-level rather than phrase-level. A candidate is
430
+ * only interesting if the store cannot already express it, and a phrase built
431
+ * entirely from words the store governs ("token registry") is a recombination
432
+ * of known vocabulary rather than new vocabulary — the store can already reach
433
+ * it, and proposing it for minting would ask a steward to approve a synonym for
434
+ * something already minted.
435
+ *
436
+ * Every governed vocabulary streams in: concept terms and aliases, leaf terms,
437
+ * and every registry's minted AND suppressed values. Suppressed values are
438
+ * included on purpose — a suppressed value is one the store has ACCOUNTED FOR,
439
+ * and re-proposing it as a novel candidate would ask a steward to re-decide a
440
+ * question they already answered.
441
+ *
442
+ * @param {object} model the loaded store model
443
+ * @returns {Set<string>} folded single words
444
+ */
445
+ export function knownVocabulary(model) {
446
+ const words = new Set();
447
+ const add = (text) => {
448
+ if (typeof text !== 'string') return;
449
+ for (const word of foldPhrase(text).split(' ')) if (word) words.add(word);
450
+ };
451
+ for (const { record } of model.concepts.values()) {
452
+ add(record.term);
453
+ for (const alias of record.aliases ?? []) add(alias);
454
+ }
455
+ for (const entry of model.leaves.values()) {
456
+ for (const term of entry.record?.terms ?? []) add(term);
457
+ }
458
+ for (const registry of model.registries?.values() ?? []) {
459
+ for (const value of registry.minted) add(String(value).replace(/[-_/.]+/g, ' '));
460
+ for (const value of registry.suppressed) add(String(value).replace(/[-_/.]+/g, ' '));
461
+ }
462
+ return words;
463
+ }
464
+
465
+ /**
466
+ * Extract salience candidates from the sections of one document.
467
+ *
468
+ * The three pinned signatures, then the three subtractions. Every candidate
469
+ * carries the sections it appeared in (its ADDRESSES, so a reader can open it),
470
+ * its occurrence count, and the signature that produced it — a candidate whose
471
+ * provenance is unstated is one a steward cannot weigh.
472
+ *
473
+ * @param {Array} sections from `sectionsOf`
474
+ * @param {Set<string>} known the folded known-vocabulary words
475
+ * @param {number} threshold the repetition threshold for this document's size
476
+ * @returns {Map<string, {term, count, signatures: Set, sections: Set<object>}>}
477
+ */
478
+ function extractCandidates(sections, known, threshold) {
479
+ const found = new Map();
480
+ const record = (raw, signatures, section) => {
481
+ // A sentence-initial determiner is capitalization for position, not part of
482
+ // the term — stripping it keeps one term from splitting into two candidates.
483
+ const term = stripDeterminer(foldPhrase(raw));
484
+ if (!term) return;
485
+ const words = term.split(' ');
486
+ // A phrase that was only a determiner plus one word is a sentence opening
487
+ // ("The Figma rule"), not a multi-word term. The two-word floor is applied
488
+ // AFTER stripping, so it means two words of actual term.
489
+ if (words.length < 2) return;
490
+ // Fully-known phrases are recombinations, not new vocabulary.
491
+ if (words.every((w) => known.has(w) || STOPWORDS.has(w))) return;
492
+ if (!found.has(term)) found.set(term, { term, count: 0, signatures: new Set(), sections: new Set() });
493
+ const entry = found.get(term);
494
+ // ONE OCCURRENCE, however many signatures it earned — see the span
495
+ // deduplication below.
496
+ entry.count += 1;
497
+ for (const s of signatures) entry.signatures.add(s);
498
+ // Sections are tracked by OBJECT, not by address: two sections can share a
499
+ // heading, and a Set of addresses would count them as one place.
500
+ entry.sections.add(section);
501
+ };
502
+
503
+ // Counted over the DISJOINT partition — each block once, attributed to the
504
+ // innermost section holding it. See `ownBlocks`: counting over the nested
505
+ // view would let nesting depth inflate a count past the threshold.
506
+ const owned = ownBlocks(sections);
507
+
508
+ for (const { section, text } of owned) {
509
+ // ONE SPAN, ONE OCCURRENCE — even when it earns two signatures.
510
+ //
511
+ // An emphasized Title-Case phrase (`**Provisional Market Ladder**`) matches
512
+ // both the emphasis pattern and the Title-Case pattern, and recording each
513
+ // match separately would count one occurrence twice. That inflates the
514
+ // number a steward reads as "how often did the author say this", and it
515
+ // inflates it SELECTIVELY — only for phrases that happen to be both
516
+ // emphasized and Title-Case — so the candidate ranking would order terms by
517
+ // how they were marked up rather than by how often they were used.
518
+ //
519
+ // Both signature LABELS are still recorded, because which signatures fired
520
+ // is real evidence about why the phrase is a candidate. What is deduplicated
521
+ // is the occurrence, keyed by where in the text the span started.
522
+ const spans = new Map(); // start offset -> { raw, signatures }
523
+ const mark = (index, raw, signature) => {
524
+ if (!spans.has(index)) spans.set(index, { raw, signatures: new Set() });
525
+ spans.get(index).signatures.add(signature);
526
+ };
527
+ for (const match of text.matchAll(EMPHASIS)) {
528
+ const span = match[2] ?? match[4] ?? '';
529
+ // Single words are stress, not vocabulary — see the salience header.
530
+ if (foldPhrase(span).split(' ').length < 2) continue;
531
+ // The offset of the span's TEXT, not of the markers, so an emphasized
532
+ // Title-Case phrase lands on the same key its bare Title-Case match does.
533
+ mark(match.index + match[0].indexOf(span), span, 'emphasis');
534
+ }
535
+ for (const match of text.matchAll(TITLE_CASE)) mark(match.index, match[1], 'title-case');
536
+ // Sorted by offset so recording runs in document order, not in Map
537
+ // insertion order across two passes — the counts are identical either way,
538
+ // but determinism should not depend on that being true.
539
+ for (const [, { raw, signatures }] of [...spans].sort((a, b) => a[0] - b[0])) {
540
+ record(raw, signatures, section);
541
+ }
542
+ }
543
+
544
+ // Repetition is counted DOC-WIDE, over single tokens, and only promoted to a
545
+ // candidate once the size-stepped threshold is cleared. Counted separately
546
+ // from the phrase signatures because it answers a different question — not
547
+ // "did the author mark this up" but "did the document keep coming back to
548
+ // it" — and a term can legitimately earn both.
549
+ const repeats = new Map();
550
+ for (const { section, text } of owned) {
551
+ for (const token of tokenize(text)) {
552
+ const word = singular(token);
553
+ // Stopwords, known vocabulary, short tokens, and anything path- or
554
+ // version-shaped (a dot inside) are never candidates: none of them is a
555
+ // term a steward would mint.
556
+ if (STOPWORDS.has(token) || STOPWORDS.has(word) || known.has(word)) continue;
557
+ if (word.length < 4 || word.includes('.') || word.includes('/')) continue;
558
+ if (!repeats.has(word)) repeats.set(word, { count: 0, sections: new Set() });
559
+ const entry = repeats.get(word);
560
+ entry.count += 1;
561
+ entry.sections.add(section);
562
+ }
563
+ }
564
+ for (const [word, entry] of repeats) {
565
+ if (entry.count < threshold) continue;
566
+ // Frequent AND concentrated — see `isConcentrated`. Boilerplate clears the
567
+ // count threshold in any long document and must not clear this one.
568
+ if (!isConcentrated(entry.count, entry.sections.size)) continue;
569
+ if (!found.has(word)) found.set(word, { term: word, count: 0, signatures: new Set(), sections: new Set() });
570
+ const candidate = found.get(word);
571
+ candidate.count += entry.count;
572
+ candidate.signatures.add('repetition');
573
+ for (const section of entry.sections) candidate.sections.add(section);
574
+ }
575
+
576
+ return found;
577
+ }
578
+
579
+ /**
580
+ * The identity a document candidate is suppressed by.
581
+ *
582
+ * IT IS THE SAME ENTRY GRAMMAR THE REVERSE AUDIT USES — `{ term, sourcePath }`,
583
+ * exact match, stamped by `suppressibleBy` where the candidate is built. The
584
+ * ticket's requirement is that a suppressed term is excluded from document
585
+ * candidates EXACTLY as it is from reverse-audit terms, and "exactly" has to
586
+ * mean one grammar rather than two that happen to look alike: `suppressions.js`
587
+ * commits to one strict shape and no second entry grammar, so a steward writes
588
+ * one entry and it works on both surfaces.
589
+ *
590
+ * `sourcePath` is the SUBMITTED DOCUMENT'S PATH, which mirrors the audit's
591
+ * `unmatched-anchor` reading of the field — there, it is the path of the file
592
+ * the finding is about. A candidate is about the document it was extracted
593
+ * from, so its path is the document's. The consequence is deliberate and worth
594
+ * stating: suppressing a candidate in one document does NOT suppress it in
595
+ * another, which is right, because "this phrase is not vocabulary in THIS
596
+ * document" is a weaker claim than "this phrase is never vocabulary". A
597
+ * steward who means the stronger thing writes one entry per document and the
598
+ * repetition is itself evidence the term deserves a real decision.
599
+ */
600
+ const candidateIdentity = (term, documentPath) => ({ term, sourcePath: documentPath });
601
+
602
+ /**
603
+ * Rank candidates and split them by suppression.
604
+ *
605
+ * Suppressed candidates are REPORTED AS SUPPRESSED, never silently absent —
606
+ * the same contract the audit's `suppressions.suppressed` list carries, and for
607
+ * the same reason: a term missing from a list is indistinguishable from a term
608
+ * the extractor never found, and the two demand opposite conduct (one is
609
+ * settled, the other is a gap).
610
+ *
611
+ * Sorted by count descending then term ascending, so the ranking is total and
612
+ * byte-stable. Signatures are sorted for the same reason.
613
+ */
614
+ function rankCandidates(found, documentPath, suppressionEntries) {
615
+ const all = [...found.values()]
616
+ .map((candidate) => {
617
+ // A candidate's section addresses ARE its locators: the ticket requires
618
+ // every ranked candidate to carry a section address usable for a
619
+ // just-in-time read, and this is that address in the map's own
620
+ // vocabulary. Capped, so a term that appears everywhere costs the map a
621
+ // constant rather than one entry per section (see ADDRESS_CAP).
622
+ //
623
+ // CAPPING IS A DISPLAY DECISION AND NOTHING ELSE. The uncapped section
624
+ // SET travels on the returned object (non-enumerable, so it cannot reach
625
+ // the wire) for `buildCoverageMap` to test membership against. Testing
626
+ // against the capped list instead would make a candidate invisible to
627
+ // every section past the eighth — those sections would publish empty
628
+ // candidate lists, and being otherwise identical they would fold into a
629
+ // DIFFERENT group than the first eight. The published repeat count would
630
+ // then be an artifact of the display cap rather than a fact about the
631
+ // document.
632
+ const { sections, more } = capAddresses([...candidate.sections].map((s) => s.address).sort(compare));
633
+ const published = suppressibleBy({
634
+ term: candidate.term,
635
+ count: candidate.count,
636
+ signatures: [...candidate.signatures].sort(compare),
637
+ sections,
638
+ 'sections-more': more,
639
+ }, candidateIdentity(candidate.term, documentPath));
640
+ published[IN_SECTIONS] = candidate.sections;
641
+ return published;
642
+ })
643
+ .sort((a, b) => b.count - a.count || compare(a.term, b.term));
644
+
645
+ const kept = [];
646
+ const suppressed = [];
647
+ for (const candidate of all) {
648
+ const identity = candidateIdentity(candidate.term, documentPath);
649
+ const match = suppressionEntries.some((e) => e.term === identity.term && e.sourcePath === identity.sourcePath);
650
+ (match ? suppressed : kept).push(candidate);
651
+ }
652
+ return { kept, suppressed };
653
+ }
654
+
655
+ /**
656
+ * Build the coverage map for one adapted document.
657
+ *
658
+ * `joinSection` is injected rather than imported, and that indirection is the
659
+ * point of the whole module: the resolver owns the query pipeline, so it passes
660
+ * its own joiner in, and this module cannot grow a second matcher even by
661
+ * accident. A section's join IS the query pipeline over that section's text.
662
+ *
663
+ * @param {object} args
664
+ * @param {string} args.document the submitted document's path (provenance + suppression identity)
665
+ * @param {object} args.ir the adapter's output: {adapter, hash, blocks}
666
+ * @param {object} args.model the loaded store model
667
+ * @param {(text: string) => object} args.joinSection runs the query pipeline over one section
668
+ * @param {object[]} args.suppressionEntries well-formed suppression entries
669
+ * @returns {object} the coverage map
670
+ */
671
+ export function buildCoverageMap({ document, ir, model, joinSection, suppressionEntries }) {
672
+ const sections = sectionsOf(ir.blocks);
673
+ const threshold = repetitionThreshold(ir.blocks.length);
674
+ const known = knownVocabulary(model);
675
+ const candidates = extractCandidates(sections, known, threshold);
676
+ const ranked = rankCandidates(candidates, document, suppressionEntries);
677
+
678
+ const joined = sections.map((section) => ({ section, join: joinSection(scannableText(section)) }));
679
+
680
+ // Per-section entries. ONLY sections with signal are published, and a section
681
+ // whose signal exactly REPEATS one already published is folded into it.
682
+ //
683
+ // This is the richness bound made concrete, and both halves are needed. The
684
+ // first (drop sections with no signal) keeps boilerplate out. The second
685
+ // (fold exact repeats) is what makes the map grow with richness rather than
686
+ // length: a status report with twenty-four "Week N" sections that each join
687
+ // the same leaf and introduce no vocabulary has told the reader ONE thing,
688
+ // and publishing it twenty-four times would make the map a function of how
689
+ // long the document ran. A folded section is not lost — its address and
690
+ // locator join the entry it repeats, so every occurrence stays openable, and
691
+ // `repeats` counts them. What is dropped is the redundancy, not the evidence.
692
+ //
693
+ // Exact-match folding only, deliberately. A section that joins a DIFFERENT
694
+ // set, or introduces one new candidate, is new information and is published
695
+ // in full. Anything looser would be a similarity threshold, and a map that
696
+ // silently merged two sections a reader would consider distinct is one that
697
+ // hides content — the failure this whole pipeline exists to prevent.
698
+ const perSection = [];
699
+ const bySignal = new Map();
700
+ for (const { section, join } of joined) {
701
+ // Membership reads the UNCAPPED set, keyed by section identity — never the
702
+ // published (capped) address list, and never an address string. See
703
+ // `IN_SECTIONS` and `rankCandidates`.
704
+ const sectionCandidates = ranked.kept
705
+ .filter((c) => c[IN_SECTIONS].has(section))
706
+ .map((c) => c.term);
707
+ const hasJoin = join.operations.length || join.concepts.length
708
+ || join.jurisdictions.length || join.leaves.length;
709
+ if (!hasJoin && !sectionCandidates.length) continue;
710
+ const joins = {
711
+ operations: join.operations.map((o) => o.value),
712
+ concepts: join.concepts.map((c) => c.id),
713
+ jurisdictions: join.jurisdictions.map((j) => j.value),
714
+ leaves: join.leaves.map((l) => l.id ?? l.notation),
715
+ };
716
+ const locator = section.page === undefined
717
+ ? { line: section.line, endLine: section.endLine }
718
+ : { page: section.page, endPage: section.endPage };
719
+ const signature = JSON.stringify([joins, sectionCandidates]);
720
+ const already = bySignal.get(signature);
721
+ if (already) {
722
+ // Capped like every other address list: a section repeated forty times is
723
+ // one fact, and enumerating forty locators would reintroduce the
724
+ // length-dependence the folding just removed. The count is always exact.
725
+ if (already.repeats.length < ADDRESS_CAP) already.repeats.push({ section: section.address, locator });
726
+ already['repeats-count'] += 1;
727
+ continue;
728
+ }
729
+ const entry = {
730
+ section: section.address,
731
+ // The LOCATOR, in the coordinates the source actually has — the whole
732
+ // point of the map is that an agent can open this range and read only it.
733
+ locator,
734
+ joins,
735
+ candidates: sectionCandidates,
736
+ // Every OTHER section with identical signal, addressed and locatable —
737
+ // capped, with the exact total alongside. Stable keys that may be empty
738
+ // or zero: a consumer must never need a presence check to tell "nothing
739
+ // repeated this" from "this engine predates folding".
740
+ repeats: [],
741
+ 'repeats-count': 0,
742
+ };
743
+ bySignal.set(signature, entry);
744
+ perSection.push(entry);
745
+ }
746
+
747
+ return {
748
+ document,
749
+ // The adapter's provenance and the content hash, carried straight through.
750
+ // The hash is what makes RESUBMISSION IDEMPOTENT and machine-visible: two
751
+ // byte-identical submissions produce byte-identical maps carrying the same
752
+ // hash, so a consumer dedupes by comparing one field rather than diffing.
753
+ adapter: ir.adapter,
754
+ hash: ir.hash,
755
+ ir: {
756
+ blocks: ir.blocks.length,
757
+ sections: sections.length,
758
+ // The threshold that WAS APPLIED, published rather than left implicit:
759
+ // a candidate list a reader cannot re-derive is one they cannot check,
760
+ // and the threshold is the one input to it that is not in the document.
761
+ 'repetition-threshold': threshold,
762
+ },
763
+ sections: perSection,
764
+ gather: gatherRollup(joined),
765
+ 'candidates-ranked': ranked.kept,
766
+ // Reported as suppressed, never silently absent — the audit's contract.
767
+ suppressed: ranked.suppressed,
768
+ };
769
+ }
770
+
771
+ /**
772
+ * The GATHER ROLLUP — every governed leaf the document reached, once, with the
773
+ * verdict it carries and the scope mismatches flagged.
774
+ *
775
+ * De-duplicated across sections because a leaf reached from four sections is
776
+ * ONE thing to read, and the rollup is what an agent works from: the per-section
777
+ * entries say where, the rollup says what. Each entry keeps the union of the
778
+ * sections that reached it, so "where" is never lost — it moves to a field.
779
+ *
780
+ * SCOPE MISMATCH is flagged rather than filtered. A leaf whose declared
781
+ * jurisdictions do not include any jurisdiction the DOCUMENT named is still
782
+ * published, carrying a flag that says so: a document about a us-ca launch that
783
+ * joins an eu-eaa-only constraint has found something its author needs to
784
+ * see — either the constraint travels or the document has a gap — and silently
785
+ * dropping it would answer both questions with the same silence. This is the
786
+ * document-scale reading of the same rule `applyScope` applies to a query, and
787
+ * it is a FLAG here rather than an exclusion because a document names many
788
+ * scopes across many sections, so "out of scope" is a weaker claim than it is
789
+ * for a single-scoped query.
790
+ *
791
+ * TIME VERDICTS are whatever the leaf carries — the shared `timeVerdict` the
792
+ * resolver already computed, never recomputed here. A run without `--today`
793
+ * carries `skipped` verdicts and the map says so, exactly as the resolver's
794
+ * `time-check` line does: a run that computed no freshness verdicts must never
795
+ * read like one that checked and found everything fresh.
796
+ */
797
+ function gatherRollup(joined) {
798
+ const documentScopes = new Set();
799
+ for (const { join } of joined) {
800
+ for (const j of join.jurisdictions) documentScopes.add(j.value);
801
+ }
802
+
803
+ const byLeaf = new Map();
804
+ for (const { section, join } of joined) {
805
+ for (const leaf of join.leaves) {
806
+ const key = leaf.id ?? leaf.notation;
807
+ if (!byLeaf.has(key)) byLeaf.set(key, { leaf, sections: new Set(), signals: new Set() });
808
+ const entry = byLeaf.get(key);
809
+ // By section OBJECT: two sections sharing a heading are two places this
810
+ // leaf was reached, and de-duplicating them by address would under-report.
811
+ entry.sections.add(section);
812
+ for (const signal of leaf.signals) entry.signals.add(`${signal.signal}:${signal.via}`);
813
+ // The highest score any section gave this leaf. A leaf is as strongly
814
+ // reached as its best section reached it — averaging would let a long
815
+ // document dilute a strong single join into nothing.
816
+ if (leaf.score > entry.leaf.score) entry.leaf = leaf;
817
+ }
818
+ }
819
+
820
+ const rollup = [];
821
+ for (const { leaf, sections, signals } of byLeaf.values()) {
822
+ const scopes = [...documentScopes].sort(compare);
823
+ const mismatch = leaf.applies.length && scopes.length
824
+ && !leaf.applies.some((j) => scopes.includes(j));
825
+ // Capped for the same reason a candidate's are: a leaf joined from every
826
+ // section of a long document must not cost one line per section.
827
+ const reached = capAddresses([...sections].map((s) => s.address).sort(compare));
828
+ rollup.push({
829
+ id: leaf.id,
830
+ notation: leaf.notation,
831
+ heading: leaf.heading,
832
+ file: leaf.file,
833
+ score: leaf.score,
834
+ signals: [...signals].sort(compare),
835
+ sections: reached.sections,
836
+ 'sections-more': reached.more,
837
+ verdict: leaf.time.verdict,
838
+ // Stable keys that may be null, like every other v2 field: a consumer
839
+ // must never need a presence check to tell "no scope mismatch" from "this
840
+ // engine predates the flag".
841
+ 'scope-mismatch': mismatch
842
+ ? `declares applies.jurisdictions [${leaf.applies.join(', ')}] — the document is scoped to [${scopes.join(', ')}], which this leaf does not cover; verify applicability (UCS-1156)`
843
+ : null,
844
+ demotions: leaf.demotions,
845
+ });
846
+ }
847
+ return rollup.sort((a, b) =>
848
+ b.score - a.score || compare(a.id ?? a.notation, b.id ?? b.notation));
849
+ }