Skip to content

Commit 3cb0121

Browse files
clay-goodclaude
andcommitted
fix(extract): count a singular use of a plural-defined term as a use
Mirror of the previous singular→plural fix. A term defined in the plural ("Deliverables", "Affiliates", "Parties") but used only in the singular ("each Deliverable", "an Affiliate") matched nothing, so STRUCT-005 reported it as an unused template leftover. Add regularSingular(term) — the inverse of regularPlural — and match it too. It is guarded by a round trip: a candidate singular is accepted only when re-pluralizing it reproduces the exact term, so a non-plural word ending in "s" ("Business" → "Busines" → "Businesses" ≠ "Business") is rejected and a made-up singular of an irregular ("Analysis" → "Analysi") never matches real text. Zero golden churn: no fixture in the 327-doc corpus defines a term in the plural and uses it only in the singular. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5b3841c commit 3cb0121

3 files changed

Lines changed: 48 additions & 10 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
**Vaulytica is the second pair of eyes you can cite.**
66

7-
`1,111 deterministic rules` · `20 cross-document checks` · `5 pre-disclosure checks` · `3 execution-readiness reconciliations` · `5 derived-deadline families` · `16 document sub-domains` · `88 state-law overlays (non-compete · security deposit · usury · will formalities)` · `10 export formats` · `0 servers` · `0 AI` · `5,859 passing tests` · `v9.41.0` · `MIT`
7+
`1,111 deterministic rules` · `20 cross-document checks` · `5 pre-disclosure checks` · `3 execution-readiness reconciliations` · `5 derived-deadline families` · `16 document sub-domains` · `88 state-law overlays (non-compete · security deposit · usury · will formalities)` · `10 export formats` · `0 servers` · `0 AI` · `5,860 passing tests` · `v9.41.0` · `MIT`
88

99
![Vaulytica landing page — "Drop legal docs. Get a report. Nothing leaves your browser."](docs/images/hero.png)
1010

@@ -1332,7 +1332,7 @@ npm run dev # open the printed URL
13321332
npm run build # static site → dist/
13331333
npm run typecheck # tsc --noEmit
13341334
npm run lint # eslint
1335-
npm run test # vitest — 5,859 tests, ~35s
1335+
npm run test # vitest — 5,860 tests, ~35s
13361336
npm run coverage # vitest + V8 coverage, enforces the regression floor
13371337
npm run accuracy # v5 Ground Truth harness → tools/accuracy/SCOREBOARD.md
13381338
npm run mutation # Stryker mutation score (scoped to extractors; slow, off the per-push path)

src/extract/definitions.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ describe("extractDefinitions", () => {
5151
expect(map.entries.find((e) => e.term === "Confidential Material")?.used_at.length).toBe(1);
5252
});
5353

54+
it("counts a singular use of a plural-defined term as a use", () => {
55+
// Mirror of the above: a term defined in the plural but used only in the
56+
// singular ("Deliverables" … "each Deliverable"; "Affiliates" … "an
57+
// Affiliate") is still used, not an unused template leftover.
58+
const tree = buildTree([
59+
"Definitions",
60+
'"Deliverables" means the items listed in the SOW.',
61+
'"Affiliates" means entities under common control.',
62+
"Provider shall submit each Deliverable to any Affiliate on request.",
63+
"A late Deliverable incurs a penalty.",
64+
]);
65+
const map = extractDefinitions(tree);
66+
expect(map.unused_terms).not.toContain("Deliverables");
67+
expect(map.unused_terms).not.toContain("Affiliates");
68+
});
69+
5470
it("does not treat an unrelated word sharing a prefix as a plural use", () => {
5571
const tree = buildTree([
5672
"Definitions",

src/extract/definitions.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -542,14 +542,14 @@ export function extractDefinitions(tree: DocumentTree): DefinitionMap {
542542

543543
// Pass 3: record every use of each term outside its definition.
544544
for (const entry of definitions.values()) {
545-
// Match the term as defined and its regular plural — a term defined in the
546-
// singular is routinely used in the plural ("… the Confidential Materials
547-
// it receives"), which is still a use, not a template leftover.
548-
const plural = regularPlural(entry.term);
549-
const alternatives =
550-
plural && plural !== entry.term
551-
? `${escapeRegExp(entry.term)}|${escapeRegExp(plural)}`
552-
: escapeRegExp(entry.term);
545+
// Match the term as defined and its regular plural/singular — a term
546+
// defined in one number is routinely used in the other ("Confidential
547+
// Material" … "the Confidential Materials"; "Deliverables" … "each
548+
// Deliverable"), which is still a use, not a template leftover.
549+
const variants = [entry.term, regularPlural(entry.term), regularSingular(entry.term)].filter(
550+
(v): v is string => !!v && v !== entry.term,
551+
);
552+
const alternatives = [entry.term, ...new Set(variants)].map(escapeRegExp).join("|");
553553
const needle = new RegExp(`\\b(?:${alternatives})\\b`, "g");
554554
forEachParagraph(tree, (ctx) => {
555555
// Skip the definition itself. For an express definition that is the
@@ -1272,3 +1272,25 @@ function regularPlural(term: string): string | null {
12721272
else plural = last + "s";
12731273
return head + plural;
12741274
}
1275+
1276+
/**
1277+
* Regular English singular of a plural term's final word — the mirror of
1278+
* {@link regularPlural}, for a term defined in the plural ("Deliverables",
1279+
* "Affiliates", "Parties") that the body uses in the singular. Guarded by a
1280+
* round trip: the candidate singular is only accepted when re-pluralizing it
1281+
* reproduces the exact term, so a non-plural word ending in "s" ("Business" →
1282+
* "Busines" → "Businesses" ≠ "Business") is rejected. Returns null when no
1283+
* simple rule applies.
1284+
*/
1285+
function regularSingular(term: string): string | null {
1286+
const m = /^(.*?)(\S+)$/.exec(term);
1287+
if (!m) return null;
1288+
const [, head, last] = m as unknown as [string, string, string];
1289+
let singularLast: string | null = null;
1290+
if (/[^aeiou]ies$/i.test(last)) singularLast = last.replace(/ies$/i, "y");
1291+
else if (/(ses|xes|zes|ches|shes)$/i.test(last)) singularLast = last.replace(/es$/i, "");
1292+
else if (/[^s]s$/i.test(last)) singularLast = last.replace(/s$/i, "");
1293+
if (!singularLast) return null;
1294+
const candidate = head + singularLast;
1295+
return regularPlural(candidate) === term ? candidate : null;
1296+
}

0 commit comments

Comments
 (0)