switchroom 0.21.17 → 0.21.18

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.
@@ -0,0 +1,697 @@
1
+ /**
2
+ * Tier-1 directive⇄rules EQUIVALENCE check — the deterministic (no-model,
3
+ * no-network) half of the Memory v2 M3 directive-flip UAT gate.
4
+ *
5
+ * Before an agent is flipped (`memory.inject_directives: false`), its
6
+ * always-on directive residue moves into the CLAUDE.md rules block. This
7
+ * module proves — by construction, with no model in the loop — that the
8
+ * migrated rules still carry every guardrail the directives did: nothing
9
+ * dropped, nothing silently truncated, nothing invented.
10
+ *
11
+ * It is PURE and UNIT-TESTABLE:
12
+ * - the injected side is the directive objects the recall hook actually
13
+ * cached (`directives_cache.<agent>.json`, schema {id,name,content,
14
+ * priority}); the caller cross-checks that cache against the live bank
15
+ * before handing it here (same admin resolution as `resolveDirectiveAdmin`
16
+ * in `src/cli/memory-directive.ts`).
17
+ * - the rules side is parsed with {@link parseRulesBlock} from
18
+ * `src/memory/rules-block.ts` — IMPORTED, never re-implemented, so the
19
+ * parser this check trusts is byte-for-byte the one the store writes.
20
+ * - the residue filter reuses {@link RESIDUE_CATEGORIES} from
21
+ * `src/memory/directive-residue.ts`, so "which directives must have a
22
+ * rule" is the SAME set the byte-budget harness measures.
23
+ *
24
+ * The equivalence contract (all four must hold for PASS):
25
+ * (a) every ACTIVE residue directive (categories rules-block +
26
+ * reflect-directive) appears in `mapping`, mapped to a PRESENT rule id
27
+ * or an explicit `retired:<reason>`. Unmapped, or mapped to an absent
28
+ * rule / an empty retirement reason ⇒ `missing_from_rules`.
29
+ * (b) for each directive→rule pair, the normalized rule text preserves every
30
+ * GUARDRAIL the directive carries — its polarity/scope modals (matched by
31
+ * synonym class, so "don't"≡"NEVER" and "only"≡"sole"), its load-bearing
32
+ * facts (ALL-CAPS names, case/instrument codes), and any required-verbatim
33
+ * quoted line — and is not truncated ⇒ `truncated_or_drifted`. ILLUSTRATIVE
34
+ * tokens (sample toasts/commands the directive quoted as examples, incidental
35
+ * prose proper nouns) are NOT demanded; requiring them verbatim in a ~400B
36
+ * rule condensed from a ~10KB directive is a condensation artifact, not a
37
+ * dropped guardrail. See the tokenizer section for the exact class split.
38
+ * (c) the rendered rules block is ≤ 6144 bytes AND the sentinel's rule count
39
+ * equals the actual rule count.
40
+ * (d) reverse: no rule lacks a mapping source ⇒ `unsourced_rules`.
41
+ *
42
+ * Empty all three lists AND (c) holding ⇒ PASS.
43
+ */
44
+
45
+ import {
46
+ parseRulesBlock,
47
+ renderRulesBlock,
48
+ RULES_BLOCK_BUDGET_BYTES,
49
+ type Rule,
50
+ type ParsedRulesBlock,
51
+ } from "../../../src/memory/rules-block.js";
52
+ import { RESIDUE_CATEGORIES } from "../../../src/memory/directive-residue.js";
53
+
54
+ /** The injected side: one row of `directives_cache.<agent>.json`. `category`
55
+ * / `isActive` are optional — when the caller has classified the directive
56
+ * (via `buildDirectiveTriageRows`) it passes them so this module can filter
57
+ * to the residue set itself; when absent, the directive is treated as an
58
+ * active residue directive already (caller pre-filtered). */
59
+ export interface FlipDirective {
60
+ id: string;
61
+ name: string;
62
+ content: string;
63
+ priority: number;
64
+ /** One of the E-45 triage categories, when known. */
65
+ category?: string;
66
+ /** Defaults to true when omitted. */
67
+ isActive?: boolean;
68
+ }
69
+
70
+ /**
71
+ * `mapping[directiveId]` is either a rule id (e.g. `R-01`) that must be
72
+ * present in the parsed block, or the literal `retired:<reason>` marking a
73
+ * directive deliberately dropped (with a non-empty reason). A directive id
74
+ * absent from this map is an unmapped guardrail ⇒ FAIL.
75
+ */
76
+ export type DirectiveRuleMapping = Record<string, string>;
77
+
78
+ const RETIRED_PREFIX = "retired:";
79
+
80
+ export interface MissingEntry {
81
+ id: string;
82
+ name: string;
83
+ /** Why it counts as missing: `unmapped`, `absent-rule`, or `empty-retire-reason`. */
84
+ reason: "unmapped" | "absent-rule" | "empty-retire-reason";
85
+ /** The mapping target, when there was one. */
86
+ mappedTo?: string;
87
+ }
88
+
89
+ export interface DriftEntry {
90
+ id: string;
91
+ name: string;
92
+ ruleId: string;
93
+ /** Directive keywords not found in the rule text. */
94
+ missingKeywords: string[];
95
+ /** True when the rule text looks truncated (ellipsis or strict prefix). */
96
+ truncated: boolean;
97
+ }
98
+
99
+ export interface UnsourcedRule {
100
+ id: string;
101
+ text: string;
102
+ }
103
+
104
+ export interface EquivalenceReport {
105
+ pass: boolean;
106
+ missing_from_rules: MissingEntry[];
107
+ truncated_or_drifted: DriftEntry[];
108
+ unsourced_rules: UnsourcedRule[];
109
+ /** Rendered bytes of the rules block (renderRulesBlock over the parsed set). */
110
+ renderedBytes: number;
111
+ budgetBytes: number;
112
+ withinBudget: boolean;
113
+ /** Sentinel's declared rule count (null when no sentinel line was parsed). */
114
+ sentinelCount: number | null;
115
+ ruleCount: number;
116
+ sentinelMatchesCount: boolean;
117
+ /** How many directives formed the residue obligation set. */
118
+ residueDirectiveCount: number;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Calibrated tokenizer — GUARDRAIL vs ILLUSTRATIVE keyword classes
123
+ // ---------------------------------------------------------------------------
124
+ //
125
+ // M2→M3 triage condenses a ~10KB verbose directive into a ~400B rule. The old
126
+ // tokenizer demanded every quoted phrase / proper noun / exact modal token
127
+ // survive verbatim, which false-flagged ~100% of valid condensed drafts (a
128
+ // "don't"→"NEVER" reword, a "only"→"sole" reword, a dropped illustrative
129
+ // example). This tokenizer separates what a guardrail actually IS (polarity,
130
+ // scope, load-bearing named facts, required-verbatim wording) from the prose
131
+ // the directive used to explain it (samples, incidental proper nouns), and
132
+ // demands only the former. The failure mode we refuse to introduce is the
133
+ // inverse: a calibration so loose it greenlights a genuinely dropped guardrail.
134
+ // The `truncated_or_drifted` acceptance tests pin that boundary.
135
+
136
+ /**
137
+ * Synonym classes for the scope/polarity modals a guardrail cannot lose. A
138
+ * class is TRIGGERED when the directive uses one of its `trigger` words; the
139
+ * obligation is SATISFIED when the rule contains ANY of the (broader) `satisfy`
140
+ * words. So "don't send" ≡ "NEVER send" ≡ "do not send" (all `neg`), and
141
+ * "only Ken" ≡ "Ken alone" via "sole"/"solely" (`only`). Deliberately small,
142
+ * explicit, and documented — not a thesaurus.
143
+ *
144
+ * Trigger sets are the DISTINCTIVE, unambiguous scope/polarity words only
145
+ * (never/cannot/only/always/…), NOT incidental "no"/"not"/"all"/"each" which
146
+ * appear constantly in non-scope senses and would over-trigger. Satisfy sets
147
+ * are broad so any faithful reword counts. Plain obligation "must"/"shall" is
148
+ * intentionally NOT a class: it marks obligation strength, not polarity or
149
+ * scope, and an imperative reword ("Always call X" / "Confirm before Y")
150
+ * preserves the obligation without the literal token — demanding it survive is
151
+ * a condensation artifact with no safety loss (every rule in the block is
152
+ * already mandatory by construction).
153
+ *
154
+ * SATISFACTION IS SPAN-SCOPED, not whole-rule (PR #4771 adversarial review,
155
+ * MAJOR 1). A satisfy token counts only when it GOVERNS THE SAME SPAN the modal
156
+ * governed in the directive — i.e. it sits within a bounded window of a content
157
+ * word the directive used near its trigger (see {@link ruleSatisfiesModal}).
158
+ * Matching a satisfy token ANYWHERE in the ~400B rule let an INVERTED
159
+ * condensation pass: "Never call Ian the executor without a grant" → "Ian is
160
+ * the executor without a grant" dropped the negation yet satisfied `neg` via the
161
+ * surviving, unrelated "without". Two changes close that:
162
+ * (a) `without` is removed from `neg.satisfy`. It is a PREPOSITION, not a
163
+ * clausal polarity marker — it modifies its own object ("without a grant")
164
+ * and carries no reliable sentence polarity, so it survived the inversion
165
+ * sitting beside the SAME span the lost "never" governed, defeating any
166
+ * proximity check. No genuine condensation relies on a bare "without" as
167
+ * its ONLY negation marker (faithful ones keep "no"/"not"/"never"/"don't").
168
+ * (b) the span/proximity check above (the robust mechanism).
169
+ *
170
+ * NOTE — the review's fallback also proposed dropping `no`/`nor`/`none`/`avoid`
171
+ * (neg) and `just`/`alone`/`purely` (only). Validation against the 5 real M2→M3
172
+ * drafts CONTRADICTED that: carrie condenses "does NOT want …"/"don't ship …"/
173
+ * "No employer-bashing"/"no public URL" into rules that carry the negation as
174
+ * "no X" — 6 FAITHFUL condensations. Dropping `no` re-flagged all 6, reviving the
175
+ * exact false-positive class this gate exists to kill. With the span/proximity
176
+ * check now scoping satisfaction, those reliable negators no longer need
177
+ * dropping (a relocated survivor no longer satisfies), so only the one proven
178
+ * falsifier-enabler (`without`) is removed. Evidence: triage/carrie + the
179
+ * `adversarial false-negatives` acceptance tests.
180
+ */
181
+ const MODAL_CLASSES = {
182
+ neg: {
183
+ trigger: ["never", "cannot", "can't", "don't", "do not", "won't", "must not", "may not", "shall not"],
184
+ satisfy: ["never", "no", "not", "don't", "dont", "cannot", "can't", "cant", "won't", "wont", "shan't", "none", "nor", "avoid", "refuse", "neither", "prohibit", "forbid", "ban"],
185
+ },
186
+ only: {
187
+ trigger: ["only", "sole", "solely", "exclusively", "nothing but"],
188
+ satisfy: ["only", "sole", "solely", "exclusively", "just", "alone", "purely"],
189
+ },
190
+ universal: {
191
+ // Trigger only on EXPLICIT scope phrases, NOT bare "always". Bare "always"
192
+ // is, like plain "must", usually emphasis on a rule that is already
193
+ // unconditional by construction ("always format X" ⇒ "X"), and condensation
194
+ // legitimately drops it — flagging that is a false positive with no safety
195
+ // loss. Deliberate scope phrases ("in all cases", "without exception") ARE
196
+ // load-bearing and stay mandatory, matched by any universal synonym.
197
+ trigger: ["whenever", "every time", "in all cases", "all cases", "at all times", "in every case", "without exception", "no exception"],
198
+ satisfy: ["always", "every", "each", "all", "everything", "whenever", "any", "must", "never", "no exception", "without exception"],
199
+ },
200
+ } as const;
201
+
202
+ type ModalClass = keyof typeof MODAL_CLASSES;
203
+
204
+ /** Strong required-verbatim cues. A double-quoted string counts as a GUARDRAIL
205
+ * (must survive verbatim) only when one of these immediately precedes it — the
206
+ * directive is telling the agent to emit that exact wording (e.g. a deferral
207
+ * line "…MUST end with exactly:"). Absent a cue, a quoted string is a SAMPLE
208
+ * (toast text, example message) and is illustrative. Kept narrow on purpose:
209
+ * loose cues like "say"/"append" would wrongly promote sample toasts. */
210
+ const VERBATIM_CUE_RE =
211
+ /(?:\bexactly\b|\bverbatim\b|\bword[- ]for[- ]word\b|\bverbatim wording\b|\bliteral(?:ly)?\b|\bthe exact (?:line|wording|text|phrase|words|sentence)\b|\bthese exact words\b|\bexact wording\b)\s*[:,]?\s*["“]?$/i;
212
+
213
+ /** Load-bearing named facts that condensation MUST carry through:
214
+ * - ALL-CAPS name runs (2+ consecutive ALL-CAPS words) — directives SHOUT
215
+ * these because they are load-bearing parties/executors: "GARY DAVID BROWN",
216
+ * "IAN THOMAS GOODFELLOW". Directives ALSO shout for EMPHASIS ("NO HTML",
217
+ * "THREE REFERENCE NUMBERS", "THE VIBE"), so a run is treated as a name only
218
+ * when NONE of its words is a common English word (see EMPHASIS_STOPWORDS).
219
+ * This is an NER-lite pre-filter, not a dictionary; a name built entirely
220
+ * from common words is inherently ambiguous and left to human adjudication.
221
+ * - case / instrument reference codes — a contiguous alnum token mixing an
222
+ * uppercase letter and a digit (AG779131P, TR10399), or a STRUCTURED
223
+ * acronym+number run with ≥2 numeric groups or a ≥5-digit group
224
+ * ("CAV 2026 00037"). Bare acronym+single-small-number ("PR 286") is an
225
+ * incidental reference, not a case code, and is excluded.
226
+ * Both are dropped to ILLUSTRATIVE when they sit in an example context
227
+ * ("e.g. AG779131P", "such as …") — a directive listing sample formats is not
228
+ * asserting a fact the rule must carry. */
229
+ const ALLCAPS_NAME_RE = /\b[A-Z]{2,}(?:\s+[A-Z]{2,}){1,}\b/g;
230
+ const CODE_TOKEN_RE = /\b(?=[A-Z0-9]*[A-Z])(?=[A-Z0-9]*\d)[A-Z0-9]{4,}\b/g;
231
+ const CODE_ACRONYM_NUM_RE = /\b[A-Z]{2,5}(?:\s+\d{2,}){1,}\b/g;
232
+
233
+ /** An `e.g.`/`such as`/`for example` marker in the ~48 chars immediately
234
+ * before a fact match ⇒ the fact is a SAMPLE, not an asserted guardrail. No
235
+ * trailing `\b` — markers ending in `.` ("e.g.") have no word boundary before
236
+ * the following space — and the run after the marker forbids sentence
237
+ * terminators (`.?!:;`) so the marker must be in the SAME clause as the fact.
238
+ *
239
+ * `like` and `including` are DELIBERATELY EXCLUDED (PR #4771 adversarial
240
+ * review, MAJOR 2). Both are far too broad to reliably mark an illustrative
241
+ * sample: "handle probate cases including S CAV 2026 00037" and "parties like
242
+ * GARY DAVID BROWN" name LOAD-BEARING facts, not examples. Treating anything
243
+ * after them as illustrative silently stopped demanding those facts, so a rule
244
+ * that dropped the code/name PASSED the gate. Only the reliable, unambiguous
245
+ * example cues remain (`e.g.`, `i.e.`, `such as`, `for example`,
246
+ * `for instance`, `example(s)`). */
247
+ const EXAMPLE_CONTEXT_RE =
248
+ /(?:\be\.?\s?g\.?|\bi\.?\s?e\.?|\bsuch as|\bfor example|\bfor instance|\bexamples?\b)[^.?!:;]{0,48}$/i;
249
+
250
+ /** Common English words a directive may SHOUT for emphasis rather than name.
251
+ * An ALL-CAPS run containing any of these is emphasis, not a party name.
252
+ * Explicit and bounded on purpose — extend as new emphasis vocabulary shows
253
+ * up in triage, never with plausible surname tokens (BROWN/DAVID/THOMAS stay
254
+ * OUT so real names survive). */
255
+ const EMPHASIS_STOPWORDS = new Set([
256
+ "THE", "A", "AN", "AND", "OR", "BUT", "NOR", "FOR", "SO", "IF", "OF", "TO",
257
+ "IN", "ON", "AT", "BY", "AS", "IS", "ARE", "BE", "NOT", "NO", "ALL", "ANY",
258
+ "EACH", "EVERY", "THIS", "THAT", "IT", "WE", "YOU", "DO", "USE", "PER",
259
+ "VIA", "YES", "NOW", "THEN", "HERE", "WITH", "WITHOUT", "ONLY", "NEVER",
260
+ "ALWAYS", "MUST", "OVER", "UNDER", "ONE", "TWO", "THREE", "FOUR", "FIVE",
261
+ "REFERENCE", "NUMBER", "NUMBERS", "DAILY", "WEEKLY", "MONTHLY", "ROLLING",
262
+ "IMPACT", "VIBE", "LINE", "LINES", "BREAK", "BREAKS", "ROW", "ROWS", "HTML",
263
+ "CSS", "JSON", "YAML", "CODE", "VERBATIM", "LITERAL", "LITERALLY", "AUTO",
264
+ "TOTAL", "NET", "TARGET", "BURN", "FIXED", "RECORDS", "WIN", "SEND",
265
+ ]);
266
+
267
+ /** True when an ALL-CAPS run reads as a load-bearing proper NAME rather than
268
+ * shouted emphasis. Requires ≥3 words (full legal names — "GARY DAVID BROWN",
269
+ * "IAN THOMAS GOODFELLOW" — clear this; two-word ALL-CAPS is far more often
270
+ * emphasis, e.g. "NO HTML"/"THE VIBE"/"COACHING FRAME", so it is treated as
271
+ * illustrative) AND no word in the common-emphasis stopword set (rejects
272
+ * three-word emphasis like "THREE REFERENCE NUMBERS"/"ROLLING WEEKLY IMPACT").
273
+ * A load-bearing two-word name must survive via a Titlecase mention or a
274
+ * required-verbatim quote (as "Fiona Jessep" does in the deferral line), not
275
+ * this ALL-CAPS pre-filter. */
276
+ function isNameRun(run: string): boolean {
277
+ const words = run.split(/\s+/);
278
+ return words.length >= 3 && words.every((w) => !EMPHASIS_STOPWORDS.has(w));
279
+ }
280
+
281
+ /** True when a structured case/instrument code (≥2 numeric groups or a ≥5-digit
282
+ * group), so an incidental "PR 286" reference does not read as a case number. */
283
+ function isStructuredCode(run: string): boolean {
284
+ const groups = run.match(/\d{2,}/g) ?? [];
285
+ return groups.length >= 2 || groups.some((g) => g.length >= 5);
286
+ }
287
+
288
+ /** True when the char span before `index` is an example-listing context. */
289
+ function inExampleContext(content: string, index: number): boolean {
290
+ return EXAMPLE_CONTEXT_RE.test(content.slice(0, index));
291
+ }
292
+
293
+ /** Exclusivity "only"/"sole"/… as a real scope word. Excludes the three ways
294
+ * "only" over-triggered on real drafts, none of which is exclusivity scope:
295
+ * - the "-only" of a compound/directive name ("fiona-facts-only", "step-only");
296
+ * - the temporal "only until/once/then/…" ("lasts only until restart");
297
+ * - the quantifier "only <number>" ("only 2.7% apart", "only 3 items"). */
298
+ const ONLY_TRIGGER_RE =
299
+ /(?<![-\w'’])(?:only|solely|exclusively|sole)\b(?!\s+(?:until|when|once|then|after|before|if|while|as|because|since|about|around|some|roughly|approximately|\d|a\s+few|[½¼¾]))/i;
300
+
301
+ /** Incidental Titlecase proper nouns (Buildkite, Ken, Playwright, Twitter).
302
+ * Extracted so the tokenizer surface stays inspectable, but classed
303
+ * ILLUSTRATIVE and never demanded — the biggest source of the old
304
+ * false-positive rate. A genuinely load-bearing name relies on the ALL-CAPS /
305
+ * code fact patterns above, or on surviving inside a required-verbatim quote. */
306
+ const PROPER_NOUN_STOPWORDS = new Set([
307
+ "The", "A", "An", "This", "That", "These", "Those", "It", "Its", "If",
308
+ "When", "While", "Do", "Don", "Set", "Use", "Never", "Always", "Only",
309
+ "Must", "Not", "No", "Every", "Each", "Any", "All", "For", "And", "But",
310
+ "Or", "So", "Then", "Prefer", "Avoid", "Ask", "Refuse", "Keep", "Treat",
311
+ "Read", "Write", "Run", "Call", "Send", "Reply", "You", "Your", "We",
312
+ "I", "Before", "After",
313
+ ]);
314
+ const PROPER_NOUN_RE = /\b[A-Z][a-zA-Z0-9_.-]*[a-z][a-zA-Z0-9_.-]*\b/g;
315
+
316
+ /** Double-quote (straight + curly) and backtick only. Single-quote extraction
317
+ * is DELETED on purpose: `'…'` matched contraction apostrophes ("Ken's own
318
+ * session … don't") and captured enormous spurious "quotes" — the single
319
+ * largest artifact source in the baseline. Real single-quoted phrases do not
320
+ * occur in these directives; the risk is not worth it. */
321
+ const DOUBLE_QUOTE_RE = /["“]([^"”]+)["”]/g;
322
+ const BACKTICK_RE = /`([^`]+)`/g;
323
+
324
+ export interface Keyword {
325
+ /** `modal` = a synonym-classed polarity/scope obligation; `quote` = a quoted
326
+ * string; `fact` = a load-bearing named fact; `proper` = an incidental
327
+ * proper noun. */
328
+ kind: "quote" | "modal" | "fact" | "proper";
329
+ /** Whether the rule MUST preserve this. Only `guardrail` keywords are
330
+ * enforced; `illustrative` keywords are extracted but never demanded. */
331
+ klass: "guardrail" | "illustrative";
332
+ /** The token as it should be searched for / reported (already trimmed). For
333
+ * modals this is the directive's own trigger word (for readable reports). */
334
+ value: string;
335
+ /** For `kind === "modal"`: which synonym class must survive. */
336
+ modalClass?: ModalClass;
337
+ /** For `kind === "modal"`: the content words the directive used near its
338
+ * trigger — the SPAN the modal governed. A surviving satisfy token in the
339
+ * rule counts only when it sits within a bounded window of one of these (see
340
+ * {@link ruleSatisfiesModal}). Empty ⇒ fall back to whole-rule containment
341
+ * (the directive gave no usable anchor, e.g. a bare "Never x."). */
342
+ anchors?: string[];
343
+ }
344
+
345
+ function normalize(s: string): string {
346
+ return s.replace(/\s+/g, " ").trim().toLowerCase();
347
+ }
348
+
349
+ function escapeRe(s: string): string {
350
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
351
+ }
352
+
353
+ /** Word-boundary containment for a single alnum-ish token (apostrophes treated
354
+ * as punctuation, so `cannot` is not satisfied by `cannon` and `never` is not
355
+ * satisfied by `nevertheless`). */
356
+ function containsWord(normalizedText: string, word: string): boolean {
357
+ const stem = normalize(word).replace(/['’]/g, "'");
358
+ if (/\s/.test(stem)) return normalizedText.includes(stem);
359
+ const re = new RegExp(`(^|[^a-z0-9])${escapeRe(stem)}([^a-z0-9]|$)`);
360
+ return re.test(normalizedText);
361
+ }
362
+
363
+ /** True when the directive triggers `cls` (uses one of its trigger words). The
364
+ * `only` class uses a refined matcher that ignores "-only" compounds and
365
+ * temporal "only until/once/…" (both over-triggered on real drafts). */
366
+ function directiveTriggersModal(normalizedContent: string, cls: ModalClass): boolean {
367
+ if (cls === "only") return ONLY_TRIGGER_RE.test(normalizedContent);
368
+ return MODAL_CLASSES[cls].trigger.some((w) => containsWord(normalizedContent, w));
369
+ }
370
+
371
+ // --- Span/proximity scoping for modal satisfaction (PR #4771 MAJOR 1) --------
372
+ //
373
+ // A modal obligation is satisfied only when a surviving satisfy token GOVERNS
374
+ // THE SAME SPAN it governed in the directive. We approximate "same span" by the
375
+ // content words the directive used within a window of its trigger (the modal's
376
+ // ANCHORS); the satisfy token in the rule must sit within a window of one of
377
+ // those anchors. Generous windows: the goal is to catch an inverted/relocated
378
+ // negation, not to re-introduce condensation false positives — a faithful
379
+ // reword keeps the modal beside the thing it negates/scopes.
380
+
381
+ /** Half-width, in characters, of the proximity window on both the directive
382
+ * (anchor harvest) and rule (satisfy match) sides. ~One clause of a ~400B
383
+ * rule; wide enough that a faithful reword passes, tight enough that a modal
384
+ * relocated to an unrelated clause does not. */
385
+ const MODAL_SPAN_WINDOW = 120;
386
+
387
+ /** Function/scaffolding words that make poor anchors — too common to pin a span
388
+ * and prone to spurious matches. Anchors must be DISTINCTIVE content words. */
389
+ const ANCHOR_STOPWORDS = new Set([
390
+ "this", "that", "these", "those", "with", "without", "from", "into", "onto",
391
+ "your", "their", "there", "here", "then", "than", "them", "they", "some",
392
+ "such", "each", "every", "which", "what", "when", "where", "while", "about",
393
+ "before", "after", "over", "under", "above", "below", "must", "should",
394
+ "would", "could", "shall", "will", "have", "been", "being", "does", "done",
395
+ "also", "only", "just", "very", "much", "more", "most", "less", "least",
396
+ "any", "all", "both", "either", "neither", "none", "unless", "until",
397
+ ]);
398
+
399
+ /** The character offset of `cls`'s first trigger occurrence in the (normalized)
400
+ * directive, or -1. `only` uses the refined, sense-aware matcher. */
401
+ function firstTriggerIndex(normalizedContent: string, cls: ModalClass): number {
402
+ if (cls === "only") {
403
+ const m = ONLY_TRIGGER_RE.exec(normalizedContent);
404
+ return m ? m.index : -1;
405
+ }
406
+ let best = -1;
407
+ for (const w of MODAL_CLASSES[cls].trigger) {
408
+ const stem = normalize(w).replace(/['’]/g, "'");
409
+ const re = new RegExp(`(?:^|[^a-z0-9])(${escapeRe(stem)})(?:[^a-z0-9]|$)`);
410
+ const m = re.exec(normalizedContent);
411
+ if (m) {
412
+ const idx = m.index + m[0].indexOf(m[1]);
413
+ if (best === -1 || idx < best) best = idx;
414
+ }
415
+ }
416
+ return best;
417
+ }
418
+
419
+ /** Distinctive content words (≥4 chars, not a stopword) within
420
+ * ±MODAL_SPAN_WINDOW of `centerIdx` — the span the modal governs. */
421
+ function anchorsAround(normalizedContent: string, centerIdx: number): string[] {
422
+ if (centerIdx < 0) return [];
423
+ const lo = Math.max(0, centerIdx - MODAL_SPAN_WINDOW);
424
+ const hi = Math.min(normalizedContent.length, centerIdx + MODAL_SPAN_WINDOW);
425
+ const window = normalizedContent.slice(lo, hi);
426
+ const words = window.match(/[a-z0-9]{4,}/g) ?? [];
427
+ const out: string[] = [];
428
+ const seen = new Set<string>();
429
+ for (const w of words) {
430
+ if (ANCHOR_STOPWORDS.has(w) || seen.has(w)) continue;
431
+ seen.add(w);
432
+ out.push(w);
433
+ }
434
+ return out;
435
+ }
436
+
437
+ /** Anchors for a triggered modal class: the span words around its trigger. */
438
+ function modalAnchors(normalizedContent: string, cls: ModalClass): string[] {
439
+ return anchorsAround(normalizedContent, firstTriggerIndex(normalizedContent, cls));
440
+ }
441
+
442
+ /** All character offsets at which `word` occurs as a whole word in `text`. */
443
+ function wordOffsets(text: string, word: string): number[] {
444
+ const stem = normalize(word).replace(/['’]/g, "'");
445
+ const offsets: number[] = [];
446
+ if (/\s/.test(stem)) {
447
+ let i = text.indexOf(stem);
448
+ while (i !== -1) {
449
+ offsets.push(i);
450
+ i = text.indexOf(stem, i + 1);
451
+ }
452
+ return offsets;
453
+ }
454
+ const re = new RegExp(`(^|[^a-z0-9])(${escapeRe(stem)})([^a-z0-9]|$)`, "g");
455
+ for (let m = re.exec(text); m; m = re.exec(text)) {
456
+ offsets.push(m.index + m[1].length);
457
+ re.lastIndex = m.index + 1; // allow overlapping / adjacent matches
458
+ }
459
+ return offsets;
460
+ }
461
+
462
+ /**
463
+ * True when the rule satisfies `cls`. A satisfy token must both (i) be present
464
+ * as a whole word and (ii) sit within ±MODAL_SPAN_WINDOW of one of the modal's
465
+ * `anchors` — the span the directive's modal governed. When `anchors` is empty
466
+ * (the directive gave no distinctive span word, e.g. a bare "Never x."), it
467
+ * falls back to whole-rule containment so short guardrails still match.
468
+ *
469
+ * This is the fix for PR #4771 MAJOR 1: whole-rule containment let an unrelated
470
+ * same-class survivor (a relocated "without"/"not") satisfy a modal whose actual
471
+ * proposition the condensation had dropped or inverted.
472
+ */
473
+ function ruleSatisfiesModal(
474
+ normalizedRuleText: string,
475
+ cls: ModalClass,
476
+ anchors: readonly string[] = [],
477
+ ): boolean {
478
+ const tokens = MODAL_CLASSES[cls].satisfy;
479
+ if (anchors.length === 0) {
480
+ return tokens.some((w) => containsWord(normalizedRuleText, w));
481
+ }
482
+ for (const tok of tokens) {
483
+ for (const at of wordOffsets(normalizedRuleText, tok)) {
484
+ const lo = Math.max(0, at - MODAL_SPAN_WINDOW);
485
+ const hi = Math.min(normalizedRuleText.length, at + tok.length + MODAL_SPAN_WINDOW);
486
+ const window = normalizedRuleText.slice(lo, hi);
487
+ if (anchors.some((a) => window.includes(a))) return true;
488
+ }
489
+ }
490
+ return false;
491
+ }
492
+
493
+ /** First trigger word the directive used for `cls`, for readable reporting. */
494
+ function firstTrigger(normalizedContent: string, cls: ModalClass): string {
495
+ return MODAL_CLASSES[cls].trigger.find((w) => containsWord(normalizedContent, w)) ?? cls;
496
+ }
497
+
498
+ /**
499
+ * Extract the calibrated keyword set from a directive's content. Deterministic.
500
+ * Each keyword carries a GUARDRAIL/ILLUSTRATIVE class; only guardrail keywords
501
+ * are enforced against the rule (see {@link ruleContainsKeyword} and the drift
502
+ * loop). Exported for direct unit testing.
503
+ */
504
+ export function extractKeywords(content: string): Keyword[] {
505
+ const out: Keyword[] = [];
506
+ const seen = new Set<string>();
507
+ const push = (kw: Keyword) => {
508
+ const value = kw.value.trim();
509
+ if (value.length === 0) return;
510
+ const key = `${kw.kind}:${value.toLowerCase()}`;
511
+ if (seen.has(key)) return;
512
+ seen.add(key);
513
+ out.push({ ...kw, value });
514
+ };
515
+
516
+ const norm = normalize(content);
517
+
518
+ // (1) Modal synonym classes — guardrail. One keyword per triggered class.
519
+ // `anchors` pin the span the modal governed so satisfaction is span-scoped
520
+ // (a relocated same-class survivor no longer satisfies) — PR #4771 MAJOR 1.
521
+ for (const cls of Object.keys(MODAL_CLASSES) as ModalClass[]) {
522
+ if (directiveTriggersModal(norm, cls)) {
523
+ push({
524
+ kind: "modal",
525
+ klass: "guardrail",
526
+ value: firstTrigger(norm, cls),
527
+ modalClass: cls,
528
+ anchors: modalAnchors(norm, cls),
529
+ });
530
+ }
531
+ }
532
+
533
+ // (2) Quoted strings. Backtick = code sample ⇒ illustrative. Double-quote =
534
+ // guardrail only when a required-verbatim cue immediately precedes it.
535
+ for (const m of content.matchAll(DOUBLE_QUOTE_RE)) {
536
+ const before = content.slice(0, m.index ?? 0);
537
+ const cued = VERBATIM_CUE_RE.test(before);
538
+ push({ kind: "quote", klass: cued ? "guardrail" : "illustrative", value: m[1] });
539
+ }
540
+ for (const m of content.matchAll(BACKTICK_RE)) {
541
+ push({ kind: "quote", klass: "illustrative", value: m[1] });
542
+ }
543
+
544
+ // (3) Load-bearing facts — guardrail: ALL-CAPS proper-name runs and
545
+ // case/instrument codes a condensed rule cannot silently drop. Emphasis
546
+ // ALL-CAPS, incidental "PR 286" refs, and example-listed codes are
547
+ // downgraded to illustrative (see the helpers above).
548
+ const fact = (value: string, index: number, guardrail: boolean) =>
549
+ push({ kind: "fact", klass: guardrail && !inExampleContext(content, index) ? "guardrail" : "illustrative", value });
550
+ for (const m of content.matchAll(ALLCAPS_NAME_RE)) fact(m[0], m.index ?? 0, isNameRun(m[0]));
551
+ for (const m of content.matchAll(CODE_TOKEN_RE)) fact(m[0], m.index ?? 0, true);
552
+ for (const m of content.matchAll(CODE_ACRONYM_NUM_RE)) fact(m[0], m.index ?? 0, isStructuredCode(m[0]));
553
+
554
+ // (4) Incidental Titlecase proper nouns — illustrative (extracted, not demanded).
555
+ for (const m of content.matchAll(PROPER_NOUN_RE)) {
556
+ const w = m[0];
557
+ if (PROPER_NOUN_STOPWORDS.has(w)) continue;
558
+ push({ kind: "proper", klass: "illustrative", value: w });
559
+ }
560
+ return out;
561
+ }
562
+
563
+ /** True when `ruleText` (normalized) preserves the GUARDRAIL keyword. Modals
564
+ * are satisfied by any member of their synonym class; quoted/fact phrases are
565
+ * substring-matched (case-insensitive); single tokens are word-boundary
566
+ * matched. Illustrative keywords are always treated as preserved (never
567
+ * demanded). */
568
+ function ruleContainsKeyword(normalizedRuleText: string, kw: Keyword): boolean {
569
+ if (kw.klass === "illustrative") return true;
570
+ if (kw.kind === "modal" && kw.modalClass) {
571
+ return ruleSatisfiesModal(normalizedRuleText, kw.modalClass, kw.anchors ?? []);
572
+ }
573
+ const needle = normalize(kw.value);
574
+ if (/\s/.test(needle)) return normalizedRuleText.includes(needle);
575
+ return containsWord(normalizedRuleText, needle);
576
+ }
577
+
578
+ /** A rule text looks truncated when it ends in an ellipsis, or is a strict
579
+ * prefix of the directive content (normalized) — i.e. it was cut short. */
580
+ function looksTruncated(ruleText: string, directiveContent: string): boolean {
581
+ const t = ruleText.trim();
582
+ if (t.endsWith("…") || t.endsWith("...")) return true;
583
+ const nr = normalize(ruleText);
584
+ const nc = normalize(directiveContent);
585
+ return nr.length > 0 && nc.length > nr.length && nc.startsWith(nr);
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // The check
590
+ // ---------------------------------------------------------------------------
591
+
592
+ /** The active residue subset of the injected directives. When a directive
593
+ * carries a `category`, it must be a residue category; an explicit
594
+ * `isActive: false` excludes it. */
595
+ export function residueDirectives(directives: readonly FlipDirective[]): FlipDirective[] {
596
+ return directives.filter((d) => {
597
+ if (d.isActive === false) return false;
598
+ if (d.category !== undefined) return RESIDUE_CATEGORIES.has(d.category);
599
+ return true;
600
+ });
601
+ }
602
+
603
+ export function compareDirectivesToRules(
604
+ directives: readonly FlipDirective[],
605
+ parsedRules: ParsedRulesBlock | null,
606
+ mapping: DirectiveRuleMapping,
607
+ ): EquivalenceReport {
608
+ const rules: Rule[] = parsedRules?.rules ?? [];
609
+ const rulesById = new Map<string, Rule>(rules.map((r) => [r.id, r]));
610
+ const residue = residueDirectives(directives);
611
+
612
+ const missing_from_rules: MissingEntry[] = [];
613
+ const truncated_or_drifted: DriftEntry[] = [];
614
+ const sourcedRuleIds = new Set<string>();
615
+
616
+ for (const d of residue) {
617
+ const target = mapping[d.id];
618
+ if (target === undefined) {
619
+ missing_from_rules.push({ id: d.id, name: d.name, reason: "unmapped" });
620
+ continue;
621
+ }
622
+ if (target.startsWith(RETIRED_PREFIX)) {
623
+ const reason = target.slice(RETIRED_PREFIX.length).trim();
624
+ if (reason.length === 0) {
625
+ missing_from_rules.push({
626
+ id: d.id,
627
+ name: d.name,
628
+ reason: "empty-retire-reason",
629
+ mappedTo: target,
630
+ });
631
+ }
632
+ // A valid `retired:<reason>` is a satisfied obligation — no rule needed.
633
+ continue;
634
+ }
635
+ const rule = rulesById.get(target);
636
+ if (!rule) {
637
+ missing_from_rules.push({
638
+ id: d.id,
639
+ name: d.name,
640
+ reason: "absent-rule",
641
+ mappedTo: target,
642
+ });
643
+ continue;
644
+ }
645
+ sourcedRuleIds.add(rule.id);
646
+
647
+ // (b) drift / truncation.
648
+ const normalizedRuleText = normalize(rule.text);
649
+ const missingKeywords = extractKeywords(d.content)
650
+ .filter((kw) => !ruleContainsKeyword(normalizedRuleText, kw))
651
+ .map((kw) => kw.value);
652
+ const truncated = looksTruncated(rule.text, d.content);
653
+ if (missingKeywords.length > 0 || truncated) {
654
+ truncated_or_drifted.push({
655
+ id: d.id,
656
+ name: d.name,
657
+ ruleId: rule.id,
658
+ missingKeywords,
659
+ truncated,
660
+ });
661
+ }
662
+ }
663
+
664
+ // (d) reverse: every rule must have a directive sourcing it.
665
+ const unsourced_rules: UnsourcedRule[] = rules
666
+ .filter((r) => !sourcedRuleIds.has(r.id))
667
+ .map((r) => ({ id: r.id, text: r.text }));
668
+
669
+ // (c) budget + sentinel integrity.
670
+ const rendered = renderRulesBlock(rules);
671
+ const renderedBytes = Buffer.byteLength(rendered, "utf8");
672
+ const withinBudget = renderedBytes <= RULES_BLOCK_BUDGET_BYTES;
673
+ const sentinelCount = parsedRules?.sentinel?.count ?? null;
674
+ const ruleCount = rules.length;
675
+ const sentinelMatchesCount = sentinelCount === ruleCount;
676
+
677
+ const pass =
678
+ missing_from_rules.length === 0 &&
679
+ truncated_or_drifted.length === 0 &&
680
+ unsourced_rules.length === 0 &&
681
+ withinBudget &&
682
+ sentinelMatchesCount;
683
+
684
+ return {
685
+ pass,
686
+ missing_from_rules,
687
+ truncated_or_drifted,
688
+ unsourced_rules,
689
+ renderedBytes,
690
+ budgetBytes: RULES_BLOCK_BUDGET_BYTES,
691
+ withinBudget,
692
+ sentinelCount,
693
+ ruleCount,
694
+ sentinelMatchesCount,
695
+ residueDirectiveCount: residue.length,
696
+ };
697
+ }