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.
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +167 -0
- package/cli/.gitkeep +0 -0
- package/cli/commands/init-copy.js +90 -0
- package/cli/commands/init.js +386 -0
- package/cli/init-copy.js +24 -0
- package/cli/init.js +24 -0
- package/cli/kit.manifest.yaml +256 -0
- package/cli/lib/copy-payload.js +334 -0
- package/cli/lib/generate-wrappers.js +162 -0
- package/package.json +36 -0
- package/payload/adapter-fixtures/README.md +53 -0
- package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
- package/payload/adapter-fixtures/html/sample.html +38 -0
- package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
- package/payload/adapter-fixtures/md/sample.md +32 -0
- package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
- package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
- package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
- package/payload/adapter-fixtures/txt/sample.txt +18 -0
- package/payload/docs/README.md +102 -0
- package/payload/docs/boundaries.md +60 -0
- package/payload/docs/ci-wiring.md +109 -0
- package/payload/docs/steward-guide.md +238 -0
- package/payload/engine/audit.js +34 -0
- package/payload/engine/commands/audit.js +289 -0
- package/payload/engine/commands/derive.js +334 -0
- package/payload/engine/commands/ingest.js +124 -0
- package/payload/engine/commands/log-entry.js +85 -0
- package/payload/engine/commands/phoenix.js +206 -0
- package/payload/engine/commands/preflight.js +530 -0
- package/payload/engine/commands/resolve.js +1678 -0
- package/payload/engine/commands/survey-map.js +351 -0
- package/payload/engine/commands/validate-values.js +315 -0
- package/payload/engine/commands/validate.js +1426 -0
- package/payload/engine/derive.js +34 -0
- package/payload/engine/ingest.js +34 -0
- package/payload/engine/lib/anchor-signatures.js +126 -0
- package/payload/engine/lib/boot.js +39 -0
- package/payload/engine/lib/call-numbers.js +133 -0
- package/payload/engine/lib/cli.js +147 -0
- package/payload/engine/lib/coverage.js +849 -0
- package/payload/engine/lib/decomposition.js +225 -0
- package/payload/engine/lib/derived.js +494 -0
- package/payload/engine/lib/engine-refusal.js +40 -0
- package/payload/engine/lib/exit-codes.js +14 -0
- package/payload/engine/lib/extractor-kinds.js +955 -0
- package/payload/engine/lib/format-adapters.js +802 -0
- package/payload/engine/lib/id-grammars.js +178 -0
- package/payload/engine/lib/iso-date.js +55 -0
- package/payload/engine/lib/kit-root.js +101 -0
- package/payload/engine/lib/load-stores.js +1624 -0
- package/payload/engine/lib/log-entry.js +196 -0
- package/payload/engine/lib/phoenix.js +628 -0
- package/payload/engine/lib/scoring.js +150 -0
- package/payload/engine/lib/suppressions.js +172 -0
- package/payload/engine/lib/time-verdicts.js +282 -0
- package/payload/engine/lib/usage-error.js +14 -0
- package/payload/engine/lib/validate-record.js +504 -0
- package/payload/engine/log-entry.js +34 -0
- package/payload/engine/phoenix.js +39 -0
- package/payload/engine/preflight.js +34 -0
- package/payload/engine/resolve.js +34 -0
- package/payload/engine/survey-map.js +34 -0
- package/payload/engine/validate-values.js +34 -0
- package/payload/engine/validate.js +34 -0
- package/payload/extractor-fixtures/.gitkeep +0 -0
- package/payload/extractor-fixtures/README.md +29 -0
- package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
- package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
- package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
- package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
- package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
- package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
- package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
- package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
- package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
- package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
- package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
- package/payload/hooks/pre-commit +37 -0
- package/payload/hooks/reverse-lookup +66 -0
- package/payload/package.json +3 -0
- package/payload/protocol/.gitkeep +0 -0
- package/payload/protocol/AGENTS.md +239 -0
- package/payload/protocol/derived-layer.md +174 -0
- package/payload/protocol/new-kind-pipeline.md +179 -0
- package/payload/protocol/registry-warrant.md +162 -0
- package/payload/protocol/skills/kb-build.md +303 -0
- package/payload/protocol/skills/knowledge-audit.md +183 -0
- package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
- package/payload/protocol/skills/knowledge-reflect.md +397 -0
- package/payload/schemas/catalog.schema.json +32 -0
- package/payload/schemas/decision-entry.schema.json +122 -0
- package/payload/schemas/finding.schema.json +77 -0
- package/payload/schemas/gap.schema.json +52 -0
- package/payload/schemas/graduation-categories.schema.json +64 -0
- package/payload/schemas/knowledge-leaf.schema.json +194 -0
- package/payload/schemas/miss.schema.json +45 -0
- package/payload/schemas/ontology-concept.schema.json +115 -0
- package/payload/schemas/phoenix-event.schema.json +76 -0
- package/payload/schemas/registry.schema.json +57 -0
- package/payload/schemas/rules.schema.json +14 -0
- package/payload/schemas/survey-scope.schema.json +23 -0
- package/payload/templates/decisions/_catalog.yaml +7 -0
- package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
- package/payload/templates/decisions/phoenix-event.yaml +74 -0
- package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
- package/payload/templates/decisions/registry-minting.yaml +58 -0
- package/payload/templates/decisions/trust-graduation.yaml +120 -0
- package/payload/templates/decisions/trust-revocation.yaml +106 -0
- package/payload/templates/knowledge/_catalog.yaml +9 -0
- package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
- package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
- package/payload/templates/knowledge/_registries/domains.yaml +43 -0
- package/payload/templates/knowledge/_registries/form.yaml +38 -0
- package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
- package/payload/templates/knowledge/_registries/operations.yaml +18 -0
- package/payload/templates/knowledge/_registries/stage.yaml +53 -0
- package/payload/templates/knowledge/_rules.yaml +6 -0
- package/payload/templates/new-kind/README.md +107 -0
- package/payload/templates/new-kind/descriptor.example.yaml +18 -0
- package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
- package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
- package/payload/templates/new-kind/fixture/sample.list +7 -0
- package/payload/templates/new-kind/parser.example.js +98 -0
- package/payload/templates/ontology/_catalog.yaml +6 -0
- package/payload/templates/ontology/_rules.yaml +6 -0
- package/payload/wrappers/cursor.mdc +15 -0
- 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
|
+
}
|